diff --git a/.changeset/code-mode-repl-sessions.md b/.changeset/code-mode-repl-sessions.md new file mode 100644 index 00000000..8b74f584 --- /dev/null +++ b/.changeset/code-mode-repl-sessions.md @@ -0,0 +1,14 @@ +--- +"@caplets/core": minor +"@caplets/opencode": patch +"@caplets/pi": patch +"caplets": patch +--- + +Expand the Code Mode tool contract with optional `sessionId` reuse, `meta.sessionId` run metadata, and recovery history lookup through `recoveryRef`. + +Sessions are live reuse affordances for iterative Code Mode runs; this does not provide durable heap persistence across host restarts. + +OpenCode now accepts the optional `sessionId` argument on Code Mode tools so agents can reuse live sessions there too. + +Native integrations and remote CLI control now use `CAPLETS_REMOTE_*` exclusively for attach/client behavior. `CAPLETS_SERVER_*` remains reserved for serving/self-hosting configuration. diff --git a/.compound-engineering/config.local.example.yaml b/.compound-engineering/config.local.example.yaml new file mode 100644 index 00000000..b5cea2c0 --- /dev/null +++ b/.compound-engineering/config.local.example.yaml @@ -0,0 +1,49 @@ +# Compound Engineering -- local config +# Copy to .compound-engineering/config.local.yaml in your project root. +# All settings are optional. Invalid values fall through to defaults. + +# --- Work delegation (Codex) --- + +# work_delegate: codex # codex | false (default: false) +# work_delegate_consent: true # true | false (default: false) +# work_delegate_sandbox: yolo # yolo | full-auto (default: yolo) +# work_delegate_decision: auto # auto | ask (default: auto) +# work_delegate_model: gpt-5.4 # any valid codex model (omit to use ~/.codex/config.toml default) +# work_delegate_effort: high # minimal | low | medium | high | xhigh (omit to use ~/.codex/config.toml default) + +# --- Product pulse --- +# Settings written by /ce-product-pulse first-run interview. Re-run the skill with +# argument `setup` or `reconfigure` to edit interactively. + +# pulse_product_name: "Spiral" # used in report titles (no default) +# pulse_lookback_default: 24h # 1h | 24h | 7d | 30d (default: 24h) +# pulse_primary_event: "session_started" # the event that means "user showed up" +# pulse_value_event: "task_completed" # the event that means "user got value" +# pulse_completion_events: "onboarded,first_purchase" # comma-separated, 0-3 events +# pulse_quality_scoring: false # true | false (default: false; AI products only) +# pulse_quality_dimension: "answer accuracy" # dimension scored 1-5 when pulse_quality_scoring is true +# pulse_analytics_source: posthog # posthog | mixpanel | custom (no default) +# pulse_tracing_source: sentry # sentry | datadog | custom (no default) +# pulse_payments_source: stripe # stripe | custom (no default) +# pulse_db_enabled: false # true | false (default: false; read-only DB if true) +# pulse_metric_sources: "retention_d7=posthog,nps=delighted" # strategy-metric -> source overrides; comma-separated 'metric=source' pairs; unlisted metrics fall back to pulse_analytics_source +# pulse_pending_metrics: "retention_d7,nps" # comma-separated strategy metrics awaiting instrumentation; render as 'no data' +# pulse_excluded_metrics: "north_star" # comma-separated strategy metrics intentionally not in pulse + +# --- Output format --- +# Per-skill output format default. Selects the exclusive format the artifact +# is written in: `md` produces a markdown file, `html` produces a single +# self-contained HTML file. The two are mutually exclusive -- there is no +# sibling artifact. See DESIGN.md or your agent instructions to influence +# HTML styling. CLI arguments override these defaults; pipeline contexts +# (e.g., LFG, disable-model-invocation) always force `md` regardless. + +# plan_output: html # md | html (default: md) +# brainstorm_output: html # md | html (default: md) +# ideate_output: md # md | html (default: html -- ideation docs are human-facing, so HTML is the default; set md to opt out) + +# --- ce-promote --- +# Written automatically when you decline the Spiral setup offer in /ce-promote. +# Suppresses that one-time setup nudge in this project. Remove the key to re-enable. + +# ce_promote_spiral_optout: true # true | (absent) (default: absent -- offer once) diff --git a/.gitignore b/.gitignore index e03fe66d..e6196865 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ benchmark-results/ # autoresearch .auto/ + +# Compound Engineering local config +.compound-engineering/*.local.yaml diff --git a/AGENTS.md b/AGENTS.md index ecdca696..fefe8d65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,9 +2,9 @@ ## Commands -- Use `pnpm` only; the repo pins `pnpm@11.5.0` and requires Node `>=24`. +- Use `pnpm` only; the repo pins `pnpm@11.7.0` and requires Node `>=22`. - Install with `pnpm install --frozen-lockfile` when matching CI. -- Full local gate and pre-push hook: `pnpm verify` (`format:check -> lint -> code-mode:check-api -> typecheck -> schema:check -> test -> benchmark:check -> build`). +- Full local gate and pre-push hook: `pnpm verify` (`format:check -> lint -> code-mode:check-api -> schema:check -> docs:check -> typecheck -> test -> benchmark:check -> build`). - Fast focused checks: `pnpm format:check`, `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm build`. - Run one package: `pnpm --filter @caplets/core test`, `pnpm --filter caplets build`, or replace the filter with `@caplets/opencode`, `@caplets/pi`, `@caplets/benchmarks`. - Run one Vitest file by passing it after the package script, e.g. `pnpm --filter @caplets/core test -- test/config.test.ts`. @@ -17,13 +17,20 @@ - `packages/opencode` and `packages/pi` are native agent integrations that wrap `@caplets/core/native`; keep integration-specific schema/adapter code there. - `packages/benchmarks` owns deterministic and opt-in live coding-agent benchmarks; deterministic benchmark docs are generated from `pnpm benchmark`. -## Generated And Checked Files +## Agent skills -- Source code is the source of truth. Keep long-lived product docs in `docs/product/`, architecture docs in `docs/`, and ADRs in `docs/adr/`. Avoid committing short-lived implementation plans or design specs unless explicitly requested; if they are needed, put them in `docs/plans/` or `docs/specs/` and delete or repurpose them once they are superseded. Do not use `docs/superpowers/` in this repo. -- Config schema source of truth is Zod in `packages/core/src/config.ts`; update `schemas/caplets-config.schema.json` with `pnpm schema:generate` and verify with `pnpm schema:check`. -- Code Mode runtime API declaration source of truth is `packages/core/src/code-mode/runtime-api.d.ts`; update `packages/core/src/code-mode/runtime-api.generated.ts` with `pnpm code-mode:generate-api` and verify with `pnpm code-mode:check-api`. -- `pnpm benchmark` updates `docs/benchmarks/coding-agent.md`; `pnpm benchmark:check` fails if the committed report is stale. -- Live benchmarks are opt-in only: build first, then run `CAPLETS_BENCH_LIVE=1 pnpm benchmark:live:opencode` or `CAPLETS_BENCH_LIVE=1 pnpm benchmark:live:pi`; results are local/model-dependent and not deterministic product claims. +- Issues and PRDs live in GitHub Issues for `spiritledsoftware/caplets`; use `gh` with `--repo spiritledsoftware/caplets`. Details: `docs/agents/issue-tracker.md`. +- Triage labels are documented in `docs/agents/triage-labels.md`; current live labels include `question` for `needs-info` and `wontfix` for `wontfix`. +- This is a single-context repo. Start with `CONTEXT.md`, relevant ADRs in `docs/adr/`, and `docs/agents/domain.md`; use `STRATEGY.md`, `CONCEPTS.md`, and `docs/solutions/` when the task touches product direction, vocabulary, or documented patterns. + +## Docs And Generated Files + +- Source code is authoritative. Keep durable product docs in `docs/product/`, architecture docs in `docs/`, ADRs in `docs/adr/`, specs/plans in `docs/specs/` or `docs/plans/`, and solution patterns in `docs/solutions/`. +- Avoid committing short-lived plans unless explicitly requested. Do not use `docs/superpowers/` in this repo. +- Config schema source: `packages/core/src/config.ts`. Generate with `pnpm schema:generate`; check with `pnpm schema:check`. +- Code Mode API sources: `packages/core/src/code-mode/runtime-api.d.ts` and `packages/core/src/code-mode/platform-entry.ts`. Generate with `pnpm code-mode:generate-api`; check with `pnpm code-mode:check-api`. +- Benchmark report: `pnpm benchmark` updates `docs/benchmarks/coding-agent.md`; `pnpm benchmark:check` checks staleness. +- Live benchmarks are opt-in and local/model-dependent: build first, then run `CAPLETS_BENCH_LIVE=1 pnpm benchmark:live:opencode`, `CAPLETS_BENCH_LIVE=1 pnpm benchmark:live:pi`, or `CAPLETS_BENCH_LIVE=1 pnpm benchmark:live:pi-eval`. ## Config And Runtime Gotchas @@ -34,7 +41,7 @@ ## PR And Release Checks -- CI runs `pnpm verify` plus `pnpm changeset status --since=origin/main` on PRs unless the PR has the `no changeset` label. +- CI runs `pnpm verify` plus `pnpm changeset status --since=origin/main` on PRs unless the PR has the `[no changeset]` label. - User-facing package changes usually need a changeset; current versioning is handled by Changesets and `pnpm version-packages`/`pnpm release`. - Pre-commit only runs `pnpm lint-staged` (`oxfmt --check` and `oxlint` on staged JS/TS/config/docs files); pre-push runs the full `pnpm verify`. -- Core Alchemy deploys the public landing page only from `apps/landing`; it does not deploy Cloud Worker or dashboard paths. +- Alchemy deploy workflows are in `.github/workflows/deploy.yml` and `.github/workflows/pr-preview-deploy.yml`; check `alchemy.run.ts` and `infra/` before changing deploy behavior. diff --git a/CONCEPTS.md b/CONCEPTS.md new file mode 100644 index 00000000..f54092c6 --- /dev/null +++ b/CONCEPTS.md @@ -0,0 +1,35 @@ +# Concepts + +Shared domain vocabulary for this project -- entities, named processes, and status concepts with project-specific meaning. Seeded with core domain vocabulary, then accretes as ce-compound and ce-compound-refresh process learnings; direct edits are fine. Glossary only, not a spec or catch-all. + +## Code Mode + +### Caplet + +A configured capability surface that exposes a backend to agents through a stable handle, progressive wrapper tools, or direct tool operations. + +### Code Mode + +The Caplets execution surface where an agent runs a bounded TypeScript workflow against generated Caplet handles and receives compact structured output. + +### Code Mode Session + +A reusable live Code Mode execution context that lets adjacent calls share variables, functions, and cached setup while the runtime remains alive and compatible. + +Code Mode Sessions are intentionally runtime state, not durable saved workflows. A caller starts a fresh session by omitting the previous session handle rather than resetting or replacing it in place. + +### Recovery Journal + +A retained, redacted record of Code Mode calls for a session, used to help reconstruct setup after the live Code Mode Session is no longer available. + +Recovery Journals support auditability and reconstruction, but they do not restore heap state. They store bounded setup evidence and avoid treating persisted runtime identifiers as reusable credentials. + +### Recovery Reference + +A capability-style reference that authorizes reading a Recovery Journal. + +Recovery References are separate from session handles. Possessing a session handle may identify a live Code Mode Session, but reading retained recovery history requires the recovery-specific reference or a known cleaned-up session that can be mapped back to the same retained journal. + +### Progressive Exposure + +The Caplets exposure mode where agents discover and call backend operations through a small set of wrapper tools instead of receiving every downstream operation as a separate top-level tool. diff --git a/STRATEGY.md b/STRATEGY.md new file mode 100644 index 00000000..44807c72 --- /dev/null +++ b/STRATEGY.md @@ -0,0 +1,67 @@ +--- +name: Caplets +last_updated: 2026-06-17 +--- + +# Caplets Strategy + +## Target problem + +Coding agents get slower, more expensive, and less reliable when real backend surfaces are exposed as flat tool lists: large MCP/API setups flood context with hundreds of operations, create duplicate generic names, and force repeated model/tool round trips for discovery, schema inspection, execution, and synthesis. The hard part is preserving exact backend power, auth state, schemas, resources, prompts, results, and errors across local, remote, Cloud, and native agent setups without making the agent reason over the whole tool wall up front. + +## Our approach + +Caplets wins by being a Code Mode-first capability layer for coding agents, not a general tool catalog. It turns heterogeneous backends into typed, scoped handles so agents can discover, inspect, execute, filter, and summarize with a small decision surface, while preserving exact backend semantics and keeping auth, direct I/O, and project-local context behind Caplet-controlled boundaries. + +## Who it's for + +**Primary:** Agent power-users/builders - They're hiring Caplets to turn sprawling MCP/API/CLI surfaces into typed capabilities their coding agents can inspect, call, filter, and synthesize without a giant tool wall. + +## Key metrics + +- **Initial tool surface compression** - Reduction in initially visible tools, serialized payload bytes, approximate context tokens, and duplicate top-level names versus direct flat MCP; measured by `pnpm benchmark:check`. +- **Code Mode workflow efficiency** - Reduction in model/tool round trips, external calls, and payload tokens while preserving required evidence fields; measured by deterministic Code Mode benchmark fixtures. +- **Live task parity at lower token cost** - Live eval pass rate must match baselines before claiming token efficiency, then compare request+output tokens and tool-surface tokens. +- **Release readiness** - Full verification, CI, changeset, release, and deploy paths pass for package-impacting work; measured through `pnpm verify` and GitHub workflows. +- **Runtime diagnosability and health** - Users and agents can verify server health, remote auth, Project Binding state, exposure readiness, and Code Mode health through finite diagnostics; measured through `caplets doctor`, `/v1/healthz`, and `caplets attach --once`. + +## Tracks + +### Capability backends and shared contracts + +Expand and harden backend families, auth, schemas, media artifacts, and Caplet source handling so many tool ecosystems can enter Caplets as focused capability domains. + +_Why it serves the approach:_ The product only works if heterogeneous MCP/API/CLI surfaces keep their fidelity while presenting as inspectable Caplets instead of a flat tool wall. + +### Code Mode runtime and native agent surfaces + +Make Code Mode the dependable default surface across MCP clients, OpenCode, and Pi, with typed handles, lean generated declarations, persistent workflow affordances, and practical non-I/O platform globals. + +_Why it serves the approach:_ Code Mode is the mechanism that lets agents discover, call, filter, join, and summarize in one bounded workflow while keeping direct I/O and raw tool sprawl out of the prompt. + +### Remote runtime and Project Binding + +Make local, self-hosted remote, and Cloud-backed execution behave as one capability model, with attach, workspace routing, auth refresh, diagnostics, and safe project sync. + +_Why it serves the approach:_ The same Caplet semantics need to survive where the work runs; remote and Cloud only help if project files, credentials, attach state, and recovery paths remain explicit and trustworthy. + +### Public proof, docs, and release confidence + +Keep public docs, generated references, landing proof, deterministic benchmarks, and repo verification aligned with implementation truth. + +_Why it serves the approach:_ Caplets asks users to trust a smaller visible surface, so public claims need reproducible evidence and drift checks. + +## Not working on + +- Flattening every downstream tool into the initial tool list by default. +- Making progressive discovery the main product frame; it remains a supported mode. +- Requiring hosted Cloud for local usage. +- Exposing arbitrary shell access or direct Code Mode host/network access. +- Treating live benchmark runs as deterministic product claims. +- Returning binary or oversized media inline as blobs or base64. + +## Marketing + +**One-liner:** Give your agent capabilities, not giant tool walls. + +**Key message:** Caplets turns MCP servers, APIs, and commands into focused capability handles for compact coding-agent workflows. The proof point is not just that Caplets connects to more backends; it is that agents can complete real multi-step backend work with a smaller decision surface, fewer round trips, and claims that are checked against reproducible benchmarks. diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 094474f1..7260aa98 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -8,6 +8,7 @@ export default defineConfig({ integrations: [ starlight({ title: "Caplets Docs", + favicon: "/icon.png", logo: { src: "./src/assets/caplets-icon.png", alt: "Caplets", diff --git a/apps/docs/public/icon.png b/apps/docs/public/icon.png new file mode 100644 index 00000000..4eebda4b Binary files /dev/null and b/apps/docs/public/icon.png differ diff --git a/apps/docs/src/content/docs/code-mode.mdx b/apps/docs/src/content/docs/code-mode.mdx index 34910366..5347293e 100644 --- a/apps/docs/src/content/docs/code-mode.mdx +++ b/apps/docs/src/content/docs/code-mode.mdx @@ -52,6 +52,46 @@ uses handles such as `caplets.osv.searchTools(...)` and `caplets.osv.callTool(.. For most tasks, keep bulky raw payloads inside the Code Mode script and return compact JSON with the evidence the user needs. +## Reuse a live session + +Code Mode accepts an optional `sessionId`. Omit it to create a fresh session. Successful +fresh runs and reused runs include `meta.sessionId`; pass that value to a nearby follow-up +call when you want to reuse helper functions, variables, or cached discovery results in the +same QuickJS heap. + +```ts +const first = await caplets__code_mode({ + code: ` + var pickNames = (items) => items.map((item) => item.name); + return pickNames((await caplets.osv.searchTools("package")).items); + `, +}); + +const sessionId = first.meta?.sessionId; + +const second = await caplets__code_mode({ + sessionId, + code: ` + const tools = await caplets.osv.searchTools("version"); + return { names: pickNames(tools.items) }; + `, +}); +``` + +Unknown or expired session IDs fail before submitted code runs, using structured session +errors such as `SESSION_NOT_FOUND`. Heap state is live runtime state only; it is not durable +across process restarts or TTL eviction. Ordinary CLI calls such as `caplets code-mode +'return 1'` are one-shot and do not share heap with later CLI invocations. + +## Recovery history + +When a Code Mode session is created, successful metadata can include a `recoveryRef`. +Agents that already possess that reference can call `caplets.debug.readRecovery(...)` to read +redacted, bounded summaries of prior session runs. Recovery history helps reconstruct safe +setup code manually; it does not restore heap values, closures, timers, promises, or host +handles. A stale or unknown `sessionId` alone does not grant access to recovery history, and +there is no recent-session lookup. + ## Platform globals Code Mode also installs standard JavaScript platform globals at runtime so scripts can diff --git a/apps/docs/src/content/docs/reference/code-mode-api.mdx b/apps/docs/src/content/docs/reference/code-mode-api.mdx index 707f058d..aed6310f 100644 --- a/apps/docs/src/content/docs/reference/code-mode-api.mdx +++ b/apps/docs/src/content/docs/reference/code-mode-api.mdx @@ -76,6 +76,32 @@ return { }; ``` +Reuse a live session only when the calling client passes the `sessionId` returned in +`meta.sessionId` from a successful fresh or reused run. Unknown or expired sessions fail +before submitted code runs, and heap state is not durable across process restarts or TTL +eviction. + +Read recovery history only with a `recoveryRef` that was already returned when the session +was created: + +```ts +const history = await caplets.debug.readRecovery({ + recoveryRef: "code-mode-recovery-ref", + limit: 20, +}); + +return { + nextCursor: history.nextCursor, + setupCandidates: history.entries + .filter((entry) => entry.recoveryClassification === "setup_like") + .map((entry) => ({ summary: entry.summary, ok: entry.outcome.ok })), +}; +``` + +Recovery returns redacted, bounded summaries for reconstructing setup code manually. It does +not restore heap, closures, timers, promises, or host handles, and a stale `sessionId` does +not grant a recovery ref. + Read execution logs through the generated debug handle when a Caplet returns a log ref: ```ts @@ -92,6 +118,14 @@ return { ## Runtime handles +```ts +type JsonPrimitive = string | number | boolean | null; +``` + +```ts +type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; +``` + ```ts interface CapletHandle { readonly id: Id; @@ -129,9 +163,26 @@ interface CapletHandle { ```ts interface DebugApi { readLogs(input: ReadLogsInput): Promise; + readRecovery(input: ReadCodeModeRecoveryInput): Promise; } ``` +```ts +type CapletCard = { + id: Id; + name: string; + description: string; + useWhen?: string; + avoidWhen?: string; + tags?: string[]; + backend?: unknown; +}; +``` + +```ts +type PageInput = { limit?: number; cursor?: string }; +``` + ```ts type Page = { items: T[]; nextCursor?: string; truncated?: boolean }; ``` @@ -142,6 +193,18 @@ type CapletsResult = | { ok: false; error: CapletsError; meta?: CapletsMeta }; ``` +```ts +type CapletsMeta = { [key: string]: unknown }; +``` + +```ts +type CapletsError = { code: string; message: string; details?: unknown }; +``` + +```ts +type BackendCheckResult = unknown; +``` + ```ts type ToolSummary = { /** Exact downstream tool identifier for describeTool(name) and callTool(name,args). */ @@ -173,6 +236,70 @@ type ToolDescriptor = { }; ``` +```ts +type ObservedOutputShape = { + version: 1; + source: "observed"; + observedAt: string; + sampleCount: number; + typeScript: string; + jsonShape: JsonShape; + truncated: boolean; +}; +``` + +```ts +type JsonShape = + | { kind: "null" } + | { kind: "boolean" } + | { kind: "number" } + | { kind: "string" } + | { kind: "unknown" } + | { kind: "array"; element?: JsonShape; truncated?: boolean } + | { + kind: "object"; + fields: Record; + truncated?: boolean; + } + | { kind: "union"; variants: JsonShape[] }; +``` + +```ts +type ResourceSummary = { uri?: string; name?: string; title?: string; description?: string }; +``` + +```ts +type ResourceTemplateSummary = { + uriTemplate?: string; + name?: string; + title?: string; + description?: string; +}; +``` + +```ts +type ResourceReadResult = unknown; +``` + +```ts +type PromptSummary = { name?: string; title?: string; description?: string }; +``` + +```ts +type PromptResult = unknown; +``` + +```ts +type CompleteInput = { + ref: { type: "prompt"; name: string } | { type: "resourceTemplate"; uri: string }; + argument: { name: string; value: string }; +}; +``` + +```ts +type CompleteResult = unknown; +``` + ```ts type ReadLogsInput = { logRef: string; cursor?: string; limit?: number }; ``` @@ -180,3 +307,64 @@ type ReadLogsInput = { logRef: string; cursor?: string; limit?: number }; ```ts type ReadLogsResult = { entries: CodeModeLogEntry[]; nextCursor?: string }; ``` + +```ts +type ReadCodeModeRecoveryInput = { recoveryRef: string; cursor?: string; limit?: number }; +``` + +```ts +type CodeModeDiagnostic = { + code: string; + message: string; + severity: "error" | "warning" | "info"; + line?: number; + column?: number; +}; +``` + +```ts +type CodeModeRecoveryClassification = "setup_like" | "side_effecting" | "unknown"; +``` + +```ts +type CodeModeRecoveryEntry = { + timestamp: string; + code: string; + declarationHash: string; + outcome: { ok: true } | { ok: false; code: string; message: string }; + diagnostics: Array>; + recoveryClassification: CodeModeRecoveryClassification; + logsStored?: boolean; + summary?: string; +}; +``` + +```ts +type ReadCodeModeRecoveryResult = { entries: CodeModeRecoveryEntry[]; nextCursor?: string }; +``` + +```ts +type CodeModeLogEntry = { + level: "log" | "info" | "warn" | "error" | "debug"; + message: string; + timestamp: string; +}; +``` + +```ts +type CodeModeSessionStatus = "created" | "reused"; +``` + +```ts +type CodeModeRunMeta = { + runId: string; + traceId: string; + declarationHash: string; + durationMs: number; + timeoutMs: number; + maxTimeoutMs: number; + sessionId?: string | null; + sessionStatus?: CodeModeSessionStatus | null; + recoveryRef?: string | null; +}; +``` diff --git a/docs/adr/0001-code-mode-default-exposure.md b/docs/adr/0001-code-mode-default-exposure.md index 04e481b9..aaef43aa 100644 --- a/docs/adr/0001-code-mode-default-exposure.md +++ b/docs/adr/0001-code-mode-default-exposure.md @@ -18,14 +18,19 @@ Code Mode is the default backend exposure for Caplets. Configured backends should enter the primary agent surface as generated `caplets.` handles inside a Code Mode TypeScript script. Agents should perform discovery, exact schema inspection, tool calls, filtering, joins, and compact synthesis inside that script. +Code Mode executions may opt into a live REPL session by passing a `sessionId` returned in tool metadata. Session reuse is an affordance for iterative work against the same generated declarations and platform runtime; the live heap is process-local and not durable persistence. Recovery history is exposed through opaque `recoveryRef` metadata so agents can inspect recent runs without treating session state as a stored source of truth. + Progressive wrapper tools remain supported through the `progressive` and `progressive_and_code_mode` exposure modes. Direct MCP exposure remains supported through `direct` and `direct_and_code_mode` when the downstream surface should be registered directly. +Code Mode is not a security boundary. Backends still define the capability surface, and hosts remain responsible for process isolation, credentials, policy, and transport-level trust decisions. + ## Consequences - Product docs should describe Caplets as Code Mode first, not only as a progressive disclosure gateway. - Progressive disclosure remains an implementation and compatibility model, but not the primary product frame. - Benchmarks should report Code Mode separately from progressive modes. - Agent guidance should prefer compact one-pass Code Mode scripts for multi-step work. +- Session metadata should be documented as optional live reuse state, not as a durable workspace or database. - Long-lived docs should preserve progressive exposure details as an alternate mode, not as the main PRD. ## Evidence diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 00000000..a932aaab --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,33 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Layout + +This is a single-context repo. + +- Read root `CONTEXT.md` for Caplets product vocabulary and domain language. +- Read relevant ADRs in `docs/adr/` before changing areas covered by an architectural decision. +- If a future `CONTEXT-MAP.md` appears, treat that as a signal that the repo has moved to a multi-context layout and update this file. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root. +- **`docs/adr/`** entries that touch the area you're about to work in. +- **`STRATEGY.md`** when deciding whether proposed work fits the current product direction. +- **`CONCEPTS.md`** when orienting to shared project vocabulary. +- **`docs/solutions/`** when implementing, debugging, or making decisions in an area with documented prior solutions. + +If any optional file does not exist in a checkout, proceed silently. Do not suggest creating it upfront; producer workflows create or update these docs when terms, strategy, or decisions are actually resolved. + +## Use the glossary's vocabulary + +When your output names a domain concept in an issue title, refactor proposal, hypothesis, test name, or implementation plan, use the terms defined in `CONTEXT.md` and `CONCEPTS.md`. Do not drift to synonyms those docs explicitly avoid. + +If the concept you need is not in the glossary yet, either reconsider whether you are inventing language the project does not use, or note the gap for a vocabulary/documentation pass. + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0001 (Code Mode default exposure), but worth reopening because..._ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 00000000..5de26d26 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,22 @@ +# Issue tracker: GitHub + +Issues and PRDs for this repo live as GitHub issues in `spiritledsoftware/caplets`. Use the `gh` CLI for all issue operations. + +## Conventions + +- **Create an issue**: `gh issue create --repo spiritledsoftware/caplets --title "..." --body "..."`. Use a heredoc for multi-line bodies. +- **Read an issue**: `gh issue view --repo spiritledsoftware/caplets --comments`, filtering comments by `jq` and also fetching labels when needed. +- **List issues**: `gh issue list --repo spiritledsoftware/caplets --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. +- **Comment on an issue**: `gh issue comment --repo spiritledsoftware/caplets --body "..."` +- **Apply / remove labels**: `gh issue edit --repo spiritledsoftware/caplets --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close --repo spiritledsoftware/caplets --comment "..."` + +`gh` can infer the repo from the current clone, but commands in skills should include `--repo spiritledsoftware/caplets` when practical so worktree location does not change the target repository. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue in `spiritledsoftware/caplets`. + +## When a skill says "fetch the relevant ticket" + +Run `gh issue view --repo spiritledsoftware/caplets --comments`. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 00000000..d9955476 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,17 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used for this repo's GitHub issue tracker. + +Current GitHub labels were checked for `spiritledsoftware/caplets` on 2026-06-18. `question` and `wontfix` already exist. The other mapped labels are the desired canonical labels for triage roles and may need to be created before first use if they are still absent. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `question` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role, use the corresponding label string from this table. + +If the repo later adopts different GitHub label names, edit the right-hand column to match the live labels rather than creating duplicates. diff --git a/docs/architecture.md b/docs/architecture.md index 8adaaab9..68b63cb2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,6 +58,18 @@ Code Mode is implemented under `packages/core/src/code-mode/`. The runtime generates TypeScript declarations from the current callable Caplets, statically checks the submitted script, runs it in the sandbox, bridges handle methods back to the native service, stores logs when configured, and returns JSON-serializable results with diagnostics. +Code Mode supports optional live sessions. A run without `sessionId` creates a fresh QuickJS +heap and returns `meta.sessionId`; a run with a known live `sessionId` reuses that heap for +adjacent calls. Unknown or expired session IDs are rejected before user code executes. +Session heaps are runtime memory only and disappear on process restart or TTL eviction. + +Recovery history is keyed by `recoveryRef`, which is returned in creation metadata when +available. `caplets.debug.readRecovery()` reads redacted, bounded summaries for agents that +already have that reference. A still-retained journal can also return the same reference when +a known session ID was evicted by TTL, compatibility invalidation, or runtime restart while +the journal remains readable. It is a setup-code reconstruction aid, not heap restoration, and +unknown session IDs do not become recovery lookup paths. + Code Mode installs a browser-like, non-I/O platform surface as runtime globals for common JavaScript data shaping: base64 helpers, a minimal `Buffer` subset, `structuredClone`, URL and text encoding helpers, Web data containers such as `Headers`, `Blob`, `File`, `FormData`, streams, abort signals, `Request`/`Response`, timers, microtasks, and crypto randomness. These globals are intentionally omitted from generated Code Mode TypeScript declarations and tool prompts so the declaration payload stays focused on Caplet handles, debug helpers, and `console`. Direct I/O remains routed through Caplet handles. `fetch` is intentionally unavailable, and Code Mode does not expose Node process, module loading, filesystem, child process, or direct network APIs. diff --git a/docs/benchmarks/coding-agent.md b/docs/benchmarks/coding-agent.md index e8b5aa9a..e19955e1 100644 --- a/docs/benchmarks/coding-agent.md +++ b/docs/benchmarks/coding-agent.md @@ -59,6 +59,17 @@ Task: Discover GitHub issue/PR tools, inspect schemas or observed shapes, fetch Code Mode preserves required triage fields (`number`, `title`, `state`, `url`, `html_url`, `labels`, `created_at`, `updated_at`) while reducing external calls versus progressive disclosure by 92.3% and approximate payload tokens by 73.3%. +### Repeated Workflow Session Reuse + +Task: Evaluate adjacent release gates where helper setup can be defined once and reused across Code Mode calls. + +| Strategy | Provider requests | Tool calls | Setup-code tokens | Request overhead proxy | Elapsed time | Setup reuse rate | Task success | +| ---------------------- | ----------------: | ---------: | ----------------: | ---------------------: | -----------: | ---------------: | ------------ | +| Progressive disclosure | 6 | 12 | 620 | 980 | 18000ms | 0.0% | yes | +| Code Mode | 2 | 2 | 210 | 340 | 7200ms | 50.0% | yes | + +This deterministic metric shape validates report dimensions for repeated setup-code volume, provider requests, tool calls, token overhead proxy, elapsed time, and task success; it is not a live model win-rate claim. In this stable fixture, Code Mode reduces repeated setup-code tokens by 66.1%, provider requests by 66.7%, tool calls by 83.3%, request overhead proxy by 65.3%, and elapsed time by 60.0% while preserving task success. + ### Live Regression Guardrails The deterministic report also records live cold-agent failure classes without treating model-dependent runs as deterministic claims. Current guardrails: `code-mode-one-run-guidance`, `optional-use-avoid-hints`, `schema-error-call-signatures`, `transport-body-normalization`. diff --git a/docs/brainstorms/2026-06-17-code-mode-repl-sessions-requirements.md b/docs/brainstorms/2026-06-17-code-mode-repl-sessions-requirements.md new file mode 100644 index 00000000..249520b9 --- /dev/null +++ b/docs/brainstorms/2026-06-17-code-mode-repl-sessions-requirements.md @@ -0,0 +1,213 @@ +--- +date: 2026-06-17 +topic: code-mode-repl-sessions +--- + +# Code Mode REPL Sessions Requirements + +## Summary + +Code Mode should support reusable in-memory REPL sessions so agents can define helper functions and workflow code once, then call them again while the live runtime remains available. V1 includes a redacted expiring call journal because expired-session reconstruction and auditability are launch requirements, not a separate later feature. + +Agent-facing Code Mode guidance should make this reuse path obvious wherever the tool is exposed. Agents should learn from tool metadata and prompt guidance to omit `sessionId` for a fresh session, capture `meta.sessionId`, pass it back for reuse, expect unknown sessions to fail, and use recovery metadata when available. + +--- + +## Problem Frame + +Agents repeat the same Code Mode setup code when they perform the same task over time. Current Code Mode is optimized for a compact one-shot TypeScript workflow, but that forces repeated helper definitions, discovery routines, and result-shaping code across adjacent calls. + +Persisting a live JavaScript heap across agent restarts is not worth the complexity for V1. The useful middle ground is live-process REPL reuse plus enough stored call history to recover context when the live session disappears. + +The product bet should be validated with `pi-eval`: add or identify a repeated-workflow scenario where helper definitions or result-shaping code would otherwise be rewritten across adjacent Code Mode calls, then compare the session-enabled path against the current one-shot path. + +--- + +## Key Decisions + +- **Session reuse lives on the existing Code Mode tool.** Agents pass an optional `sessionId` to reuse a REPL context instead of switching to a separate reset or session-management tool. +- **Omitting `sessionId` creates a fresh reusable session.** The tool result includes the generated session ID in metadata so the agent can reuse it in later calls. +- **Unknown sessions fail before execution.** If an agent supplies a session ID that Caplets cannot find, Caplets does not run the code in an empty context. +- **Expired sessions can still explain themselves.** If the live REPL is gone but its journal remains, the failure points the agent to reconstruction history instead of returning a generic missing-session error. +- **Session IDs do not bypass authorization.** Reusing a session or reading its journal must stay inside the originating Code Mode security scope. +- **Live reuse has a concrete expectation.** Sessions are process-local, but adjacent calls in the same healthy runtime should normally reuse state unless an idle TTL, compatibility change, lifecycle event, or explicit resource pressure evicts the context. +- **Journal entries support safe reconstruction.** Recovery history distinguishes setup-like code from side-effecting calls so agents do not blindly replay everything. +- **Durable heap persistence is out of scope.** Caplets stores reconstruction breadcrumbs, not QuickJS heap snapshots, closures, promises, timers, or live host handles. +- **REPL reuse must be taught at the tool surface.** The behavior is only useful if agents see the reuse contract in the Code Mode tool description, `sessionId` input schema, and native prompt guidance before they decide how to call the tool. + +```mermaid +flowchart TB + A["code_mode call without sessionId"] --> B["Create live REPL session"] + B --> C["Return sessionId in metadata"] + C --> D["Agent reuses sessionId"] + D --> E{"Live session exists?"} + E -->|yes| F["Run in existing context"] + E -->|no| G{"History still retained?"} + G -->|yes| H["Fail with reconstruction pointer"] + G -->|no| I["Fail as unknown session"] +``` + +--- + +## Actors + +- A1. **Agent.** Writes Code Mode scripts, stores returned session IDs in conversation context, and reuses sessions for repeated workflows. +- A2. **Caplets runtime.** Owns live REPL contexts, validates session IDs, records session call history, and enforces cleanup. +- A3. **User or reviewer.** Uses the stored journal to understand what an agent ran and why a later reconstruction may be needed. + +--- + +## Requirements + +**Session behavior** + +- R1. A Code Mode call without `sessionId` creates a new isolated REPL session and returns the generated session ID in result metadata. +- R2. A Code Mode call with a known live `sessionId` executes in that session's existing JavaScript context. +- R3. A Code Mode call with an unknown `sessionId` fails before user code execution. +- R4. A Code Mode call for a no-longer-live session with retained history fails before execution and returns a reconstruction pointer. +- R5. Starting fresh requires omitting `sessionId` and using the new session ID returned in metadata. + +**State and isolation** + +- R6. REPL state is scoped to the Code Mode session ID and must not leak across unrelated sessions. +- R7. Session reuse is authorized against the same Code Mode security principal as the session creator. If possession of the session ID is the chosen access model, IDs are high-entropy, non-public capabilities. +- R8. Sessions are process-local and best-effort, but a healthy live runtime should keep a session available across adjacent calls until an idle TTL, lifecycle event, compatibility change, or explicit resource pressure evicts it. +- R9. Caplets must not persist live JavaScript heap state, active timers, pending promises, or host bridge handles across process restarts. +- R10. A session must not silently continue after a capability declaration or runtime compatibility change that would make prior bindings misleading. + +**History and auditability** + +- R11. Caplets records each session call in a persisted, expiring journal. +- R12. Journal entries include enough information for reconstruction: timestamp, submitted code, session ID, declaration hash, outcome, diagnostics summary, log reference when available, and a setup-versus-side-effect annotation or recovery summary. +- R13. Journal entries redact common secrets and sensitive identifiers before writing to disk. +- R14. Journal storage caps entry size and retained entry count so auditability cannot become unbounded local storage. +- R15. Session journals are readable only by actors authorized for the originating session scope; redaction does not make journals public, safe for unrelated telemetry, or appropriate for broad debug access. +- R16. A retained journal can be read through a debug or recovery surface without exposing unrelated sessions. +- R17. Recovery output distinguishes reconstruction-safe setup from side-effecting calls so agents can rebuild helpers without treating the journal as an automatic replay script. + +**Agent ergonomics** + +- R18. Tool descriptions and docs teach agents to omit `sessionId` for a fresh session, capture `meta.sessionId`, and pass it on later calls when they intend to reuse definitions. +- R19. Errors for expired sessions should tell the agent whether reconstruction history exists and how to retrieve it. +- R20. The default path remains simple for one-shot Code Mode use: agents can ignore `sessionId` when reuse is not useful. +- R21. If an agent loses the returned session ID while the live context still exists, Caplets provides a scoped recent-session lookup or durable conversation/run binding that can recover eligible active sessions without exposing unrelated sessions. +- R22. The `sessionId` input description tells agents that known IDs reuse existing REPL state and missing IDs fail instead of starting an empty context. +- R23. Code Mode tool descriptions explain which bindings survive reuse: successful top-level `var` bindings, function declarations, and runtime state inside the live session. +- R24. Native prompt guidance for Pi, OpenCode, and other native surfaces reinforces the same reuse contract as MCP tool metadata. +- R25. Recovery metadata guidance explains that `recoveryRef` is for audit and reconstruction, not automatic replay. + +**Validation** + +- R26. `pi-eval` validates the feature with a repeated-workflow task that compares the current one-shot Code Mode path against session-enabled reuse using task success, provider request count, tool-call count, token/request overhead, and repeated setup-code volume. +- R27. Tests cover the generated Code Mode tool description, `sessionId` schema description, and native prompt guidance so the reuse contract does not drift across surfaces. + +--- + +## Key Flows + +- F1. Fresh reusable session + - **Trigger:** The agent wants to define helpers for a repeated Code Mode workflow. + - **Actors:** A1, A2 + - **Steps:** The agent calls Code Mode without `sessionId`; Caplets creates an isolated REPL context, runs the code, records the call, and returns `meta.sessionId`. + - **Covered by:** R1, R5, R11, R18 + +- F2. Reuse live session + - **Trigger:** The agent has a prior `sessionId` and wants to reuse helper definitions or cached local variables. + - **Actors:** A1, A2 + - **Steps:** The agent calls Code Mode with `sessionId`; Caplets validates the live session, executes in the existing context, records the call, and returns normal run metadata. + - **Covered by:** R2, R6, R7, R8, R11, R22, R23 + +- F3. Expired session reconstruction + - **Trigger:** The agent resumes with a `sessionId` whose live context has been cleaned up. + - **Actors:** A1, A2, A3 + - **Steps:** Caplets refuses to execute in a fresh empty context, returns an expired-session failure with a reconstruction pointer, and the agent reads the journal to reconstruct useful helper code. + - **Covered by:** R4, R12, R15, R16, R17, R19, R25 + +- F4. Unknown session failure + - **Trigger:** The agent supplies a session ID that Caplets has neither live state nor retained journal for. + - **Actors:** A1, A2 + - **Steps:** Caplets fails before execution and reports that the session is unknown. + - **Covered by:** R3 + +- F5. Lost session ID recovery + - **Trigger:** The agent loses the returned `sessionId`, but the live runtime may still have the session. + - **Actors:** A1, A2 + - **Steps:** The agent uses a scoped lookup or conversation/run binding; Caplets authorizes the lookup against the same session scope and returns only eligible active session candidates. + - **Covered by:** R7, R21 + +- F6. Agent learns reuse from tool metadata + - **Trigger:** The agent inspects or receives the Code Mode tool definition before deciding whether to run one-shot or reusable code. + - **Actors:** A1, A2 + - **Steps:** Caplets presents concise reuse guidance in the tool description, `sessionId` field description, and native prompt guidance; the agent starts fresh or reuses an existing session intentionally. + - **Covered by:** R18, R22, R23, R24, R25, R27 + +--- + +## Acceptance Examples + +- AE1. **Covers R1, R2, R8, R18.** Given an agent calls Code Mode without `sessionId`, when the run succeeds, then the result metadata includes a session ID the agent can pass to a later adjacent Code Mode call that reuses the same helper definitions while the live runtime remains healthy. +- AE2. **Covers R3.** Given an agent submits code that depends on previous variables with an unknown `sessionId`, when Caplets handles the request, then no user code runs and the result tells the agent the session is not available. +- AE3. **Covers R4, R12, R16, R19.** Given the live REPL has been evicted but the journal has not expired, when the agent calls Code Mode with that session ID, then Caplets returns an expired-session failure with a reconstruction pointer. +- AE4. **Covers R9.** Given the Caplets process restarts, when the agent tries to reuse a previous session, then Caplets does not attempt to restore closures or active heap objects. +- AE5. **Covers R13, R14.** Given submitted code or logs include obvious tokens or credentials, when Caplets stores session history, then the persisted journal redacts those values and enforces storage caps. +- AE6. **Covers R7, R15.** Given another actor lacks authorization for the originating session scope, when it tries to reuse the session or read the journal, then Caplets rejects the request. +- AE7. **Covers R12, R17.** Given a journal contains helper definitions and calls with external side effects, when the recovery surface renders it, then setup-like code is distinguishable from side-effecting calls. +- AE8. **Covers R21.** Given an agent loses `meta.sessionId` while the runtime still has active sessions, when it uses the scoped recovery mechanism, then Caplets returns only eligible sessions for the same authorized conversation or run scope. +- AE9. **Covers R26.** Given `pi-eval` runs a repeated-workflow task before and after session reuse ships, when results are compared, then the report shows whether session reuse improves repeated setup-code volume, tool calls, request overhead, and task success. +- AE10. **Covers R18, R22, R23, R24, R25, R27.** Given an agent sees Code Mode through MCP, Pi, OpenCode, or another native surface, when it reads the tool metadata or prompt guidance, then it can infer how to create, reuse, recover, and intentionally abandon a REPL session. + +--- + +## Product Validation + +- Use `pi-eval` as the primary validation path rather than relying on anecdotes. +- Add or select a task where the agent benefits from defining helper functions, discovery routines, or result-shaping code once and reusing them across adjacent Code Mode calls. +- Compare the current one-shot Code Mode behavior with session-enabled reuse using task success, provider request count, tool-call count, token/request overhead, repeated setup-code volume, and elapsed time when stable enough to interpret. +- Treat live eval output as directional evidence because Pi eval runs are local, model-dependent, and date-dependent. + +--- + +## Success Criteria + +- Agents can reuse helper functions and workflow state across multiple Code Mode calls in the same live runtime. +- Agents can infer the reuse workflow from Code Mode tool metadata without reading separate product docs. +- Adjacent calls in a healthy runtime normally reuse live state; eviction is an explicit recovery path, not the default experience. +- Session loss is understandable: expired sessions point to reconstruction history when available. +- Existing one-shot Code Mode workflows continue to work without session-specific setup. +- The audit journal gives useful visibility without storing unbounded or obviously sensitive raw data, and without exposing journals across unrelated session scopes. +- `pi-eval` can demonstrate whether repeated-workflow tasks become cheaper or more reliable with session reuse. + +--- + +## Scope Boundaries + +- Durable QuickJS heap snapshots are not in scope. +- Saved named workflows are not in scope. +- A separate reset tool is not in scope; agents start fresh by omitting `sessionId`. +- A broad human-facing documentation rewrite is not in scope for the tool-guidance follow-up unless it directly supports the exposed agent guidance. +- Cross-process or cross-device REPL continuity is not in scope. +- Replaying journal entries automatically is not in scope for V1 because prior calls may include side effects; recovery output only helps agents decide what is safe to reconstruct manually. + +--- + +## Dependencies / Assumptions + +- Current Code Mode can continue using QuickJS as the live execution engine. +- The existing log redaction approach can be adapted or shared for session journals. +- Agents usually have enough conversation context to store and reuse returned session IDs, but this is not the only recovery path. +- Eviction policy details can be settled during planning as long as session loss has explicit recovery behavior. +- Existing `pi-eval` infrastructure can be extended with a repeated-workflow task if no current task already stresses setup reuse. + +--- + +## Sources / Research + +- `AGENTS.md` for repo commands, package ownership, and generated Code Mode API constraints. +- `docs/product/caplets-code-mode-prd.md` for the current Code Mode product contract. +- `apps/docs/src/content/docs/code-mode.mdx` for user-facing Code Mode guidance. +- `docs/plans/2026-06-17-code-mode-platform-api-compat.md` for the adjacent platform-runtime expansion. +- `packages/core/src/code-mode/runner.ts` and `packages/core/src/code-mode/sandbox.ts` for the current one-shot runtime shape. +- `packages/core/src/code-mode/tool.ts`, `packages/core/src/serve/session.ts`, `packages/core/src/native/service.ts`, and `packages/core/src/native/remote.ts` for the current public Code Mode input surfaces. +- `packages/core/src/code-mode/declarations.ts`, `packages/core/src/native/tools.ts`, `packages/pi/src/index.ts`, and `packages/opencode/README.md` for current agent-facing Code Mode guidance surfaces. +- `packages/core/src/code-mode/logs.ts` for the existing TTL-backed redacted log-store pattern. +- `packages/benchmarks/lib/pi-eval/config.ts`, `packages/benchmarks/lib/pi-eval/suites.ts`, `packages/benchmarks/lib/pi-eval/metrics.ts`, and `docs/benchmarks/coding-agent.md` for the live Pi eval validation path. diff --git a/docs/plans/2026-06-17-002-feat-code-mode-repl-sessions-plan.md b/docs/plans/2026-06-17-002-feat-code-mode-repl-sessions-plan.md new file mode 100644 index 00000000..6fcc92cc --- /dev/null +++ b/docs/plans/2026-06-17-002-feat-code-mode-repl-sessions-plan.md @@ -0,0 +1,508 @@ +--- +title: "feat: Add Code Mode REPL sessions" +type: feat +date: 2026-06-17 +deepened: 2026-06-17 +origin: docs/brainstorms/2026-06-17-code-mode-repl-sessions-requirements.md +--- + +# feat: Add Code Mode REPL sessions + +## Summary + +Add reusable Code Mode sessions to the existing `code_mode` tool so agents can create helper functions and workflow state once, then reuse them across adjacent calls while the live runtime remains available. The implementation keeps one-shot Code Mode simple, adds scoped recovery through an expiring redacted journal, and validates the product bet with a repeated-workflow Pi eval. + +--- + +## Problem Frame + +Code Mode currently optimizes for one compact TypeScript run. That keeps tool surfaces small, but it also makes agents repeat setup helpers, discovery routines, and result-shaping code when a workflow spans multiple calls. + +The requirements intentionally stop short of durable heap persistence. V1 should preserve live QuickJS state only inside the current runtime process and persist enough redacted history for reconstruction and auditability when a session disappears. + +--- + +## Requirements + +### Session Behavior + +| ID | Requirement | +| --- | ------------------------------------------------------------------------------------------------------------------------------------- | +| R1 | A Code Mode call without `sessionId` creates a new isolated reusable session and returns the generated session ID in result metadata. | +| R2 | A Code Mode call with a known live `sessionId` executes in that session's existing JavaScript context. | +| R3 | A Code Mode call with an unknown `sessionId` fails before user code execution. | +| R4 | A Code Mode call for a no-longer-live session with retained history fails before execution and returns a reconstruction pointer. | +| R5 | Starting fresh requires omitting `sessionId` and using the new session ID returned in metadata. | + +### State, Isolation, And Compatibility + +| ID | Requirement | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| R6 | REPL state is scoped to the Code Mode session ID and does not leak across unrelated sessions. | +| R7 | Session reuse and exact journal reads are scoped to the originating Code Mode security principal; V1 treats high-entropy session IDs and recovery refs as local capabilities for exact reads where no stronger host identity exists. | +| R8 | Healthy live runtimes keep sessions available across adjacent calls until idle TTL, lifecycle close, compatibility change, or resource pressure evicts them. | +| R9 | Caplets does not persist QuickJS heap state, timers, promises, closures, or host handles across process restarts. | +| R10 | Sessions do not silently continue after declaration, runtime platform, or compatibility changes that would make prior bindings misleading. | + +### History And Auditability + +| ID | Requirement | +| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| R11 | Caplets records each session call in a persisted, expiring journal. | +| R12 | Journal entries include timestamp, submitted code, a non-capability journal session key or keyed hash, declaration hash, outcome, diagnostics summary, log reference when available, and recovery classification. Raw `sessionId` and `recoveryRef` values are not persisted. | +| R13 | Journal entries redact common secrets and sensitive identifiers before writing to disk. | +| R14 | Journal storage caps entry size, retained entry count, and retention age. | +| R15 | Journal reads are authorized for the originating session scope; redaction does not make journals broad telemetry. | +| R16 | A retained journal can be read through a debug or recovery surface without exposing unrelated sessions. | +| R17 | Recovery output distinguishes setup-like calls from side-effecting or unknown calls. | + +### Agent Ergonomics And Validation + +| ID | Requirement | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| R18 | Tool descriptions and docs teach agents to capture `meta.sessionId` and pass it on later calls. | +| R19 | Expired-session errors tell the agent whether reconstruction history exists and how to retrieve it. | +| R20 | One-shot Code Mode remains usable without session-specific setup. | +| R21 | If an agent loses the returned session ID while the live context exists, Caplets provides scoped recent-session lookup only when a stable host conversation or run identity is available. Without that identity, lookup returns unsupported or empty and exact `sessionId` or `recoveryRef` possession is required. | +| R22 | Pi eval validates a repeated-workflow task against one-shot and session-enabled Code Mode paths. | + +### Key Flows + +| ID | Flow | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| F1 | Fresh call: agent omits `sessionId`, Code Mode creates a session, executes the cell, journals the call, and returns `meta.sessionId`. | +| F2 | Reuse call: agent passes a live `sessionId`, Code Mode verifies scope and compatibility, executes in the existing context, journals the call, and returns the same session ID. | +| F3 | Expired call: agent passes an evicted `sessionId`, Code Mode finds retained history, fails before execution, and returns recovery metadata. | +| F4 | Unknown call: agent passes an ID with no live context or retained history, Code Mode fails before execution with a clear unknown-session error. | +| F5 | Recovery lookup: agent starts a fresh Code Mode run and uses scoped debug helpers to list recent sessions only when a stable host identity scopes the request, or reads retained journal summaries by exact recovery capability. | + +--- + +## Key Technical Decisions + +- KTD1. **Reuse the existing Code Mode tool contract.** Add optional `sessionId` to the existing run input and add `meta.sessionId` to the structured envelope. When the MCP transport supports result metadata, mirror the session ID there, but keep the envelope as the portable source of truth. +- KTD2. **Own sessions at the Code Mode runtime-service layer.** Introduce a shared `CodeModeSessionManager` used by MCP sessions, native services, remote composites, and long-lived CLI REPL processes. This avoids separate behavior between `code_mode`, `caplets__code_mode`, remote overlays, and command-line debugging without implying cross-process CLI heap reuse. +- KTD3. **Keep unreused fresh sessions behavior-compatible with one-shot runs.** Omitting `sessionId` creates a new reusable session, but a call that never reuses that ID should feel like current one-shot Code Mode from the agent's perspective: same authoring model, diagnostics, serialization, and log behavior. Cleanup happens through session lifecycle eviction rather than immediate per-call disposal. +- KTD4. **Keep REPL state in a reusable QuickJS runtime/context.** A live session owns its QuickJS runtime, context, host bridges, pending deferred tracking, compatibility key, and disposal path. QuickJS documentation treats runtime limits as aggregate over contexts and separate contexts as isolated global environments, so each Code Mode session should own a runtime rather than sharing a heap across sessions. +- KTD5. **Drive REPL semantics from behavior tests, not an untested compiler rewrite.** Session cells must preserve named top-level helper functions and `var` state while retaining the existing `return` and top-level `await` authoring style. The implementer may use a TypeScript AST transform or a split setup/body evaluation strategy, but the acceptance tests define the contract. +- KTD6. **Use conservative compatibility invalidation.** A live session is reusable only when its declaration hash, runtime platform hash, runtime scope, and code-mode compatibility version still match. On mismatch, evict before execution and journal the reason. +- KTD7. **Use high-entropy capability IDs for V1.** Generate session IDs and recovery refs with cryptographically secure randomness and at least 128 bits of entropy. OWASP session guidance requires unpredictable, meaningless IDs and a CSPRNG for session tokens; Code Mode should exceed the minimum because IDs gate local heap and journal access. +- KTD8. **Extend the debug surface, not the visible tool list.** Add scoped recovery helpers under `caplets.debug` for journal reads and identity-gated recent-session lookup. Recent-session listing is enabled only when a stable host conversation/run identity scopes the request; otherwise exact `sessionId` or `recoveryRef` possession is required. This keeps the visible agent surface compact and matches the existing `caplets.debug.readLogs()` pattern. +- KTD9. **Persist reconstruction breadcrumbs, not replay scripts.** The journal stores redacted code and summaries with a recovery classification, but it never persists raw session IDs or recovery refs. Store a non-capability journal key or keyed hash so retained audit data cannot become a token vault. Setup-like entries are candidates for manual reconstruction; side-effecting and unknown entries are visible for audit but are not automatic replay input. +- KTD10. **Bound live and durable state.** Start with a 30-minute idle TTL, 32 live sessions per runtime-service instance, and LRU eviction under pressure, while keeping existing QuickJS memory/stack limits. Journal files start with a 7-day retention TTL, 100 entries per session, 64 KiB redacted submitted-code bytes per entry, and 16 KiB summary bytes per entry. These are implementation defaults, not user-facing promises; U4 tests should pin behavior and keep future configuration possible. +- KTD11. **Validate usefulness in the existing benchmark system.** Add a repeated-workflow Pi eval path and deterministic harness coverage for the new metrics rather than claiming value from anecdotal manual runs. +- KTD12. **Keep remote Code Mode sessions local to the runtime that executes Code Mode.** Remote composites still run Code Mode locally against a remote service facade; the feature does not create server-side Cloud heap persistence or attach-session continuity. + +--- + +## High-Level Technical Design + +### Runtime Topology + +```mermaid +flowchart TB + Agent["Agent or CLI"] --> Tool["Existing Code Mode run surface"] + Tool --> Manager["CodeModeSessionManager"] + Manager --> Registry["Live session registry"] + Manager --> Journal["Session journal store"] + Registry --> Repl["QuickJS REPL session"] + Repl --> Bridge["Caplet invoke and platform host bridges"] + Bridge --> Service["NativeCapletsService / CapletsEngine"] + Repl --> Logs["CodeModeLogStore"] + Journal --> Debug["caplets.debug recovery helpers"] + Debug --> Tool +``` + +### Session Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> NewCall: no sessionId + NewCall --> Live: create session and run + Live --> Live: reuse known sessionId + Live --> Evicted: idle TTL / LRU / close + Live --> Evicted: compatibility mismatch + Evicted --> ExpiredWithJournal: journal retained + Evicted --> Unknown: journal expired + ExpiredWithJournal --> [*]: fail with recovery ref + Unknown --> [*]: fail before execution +``` + +### Recovery Flow + +```mermaid +sequenceDiagram + participant A as Agent + participant T as code_mode + participant M as Session manager + participant J as Journal store + + A->>T: run code with sessionId + T->>M: validate session + M->>J: lookup retained history + alt live session exists + M-->>T: execute in existing context + T-->>A: result with meta.sessionId + else journal retained + M-->>T: expired-session error with recoveryRef + T-->>A: failure before user code execution + A->>T: fresh run reads authorized debug recovery helper + T->>J: scoped recovery read + T-->>A: setup-like vs side-effecting journal summary + else no live session or history + M-->>T: unknown-session error + T-->>A: failure before user code execution + end +``` + +--- + +## Scope Boundaries + +### In Scope + +- Existing `code_mode` / `caplets__code_mode` run surfaces accept optional `sessionId`. +- Result envelopes return `meta.sessionId` for fresh and reused sessions. +- Live process sessions preserve reusable helper functions and state across adjacent calls. +- Expired sessions with retained journal return a recovery pointer. +- Scoped debug helpers read retained journals and list recent live sessions only when a stable host identity is available. +- CLI debugging parity is process-local: ordinary `caplets code-mode` remains one-shot, while live CLI session reuse requires an explicit long-lived `caplets code-mode repl` flow. +- Pi eval includes a repeated-workflow validation path. + +### Deferred For Later + +- Durable QuickJS heap snapshots. +- Saved named workflows. +- Cross-process or cross-device live REPL continuity. +- Automatic journal replay. +- A separate reset tool. +- Host-specific durable conversation binding when the host does not expose a stable run or conversation identity. + +### Deferred To Follow-Up Work + +- User-configurable session TTL and journal retention policy, if defaults prove insufficient. +- Rich semantic classification of side effects using downstream tool annotations beyond the conservative V1 classification. +- HTML or UI surfaces for journal inspection. + +--- + +## System-Wide Impact + +- Public contract: `code_mode` input schemas, native execution payloads, remote attach manifests, CLI REPL flags, generated runtime declarations, and docs all gain the same optional session shape. +- Runtime lifecycle: services that can execute Code Mode now own long-lived QuickJS contexts and must dispose them on close, TTL eviction, compatibility mismatch, timeout eviction, and LRU pressure. +- Security and privacy: session IDs and recovery refs become local capability tokens, so ID generation, scope checks, identity-gated lookup, journal redaction, retained-history reads, and denial behavior become part of the core security surface. +- Storage: Code Mode gains a second expiring local store beside logs. The journal must share redaction discipline with logs, avoid persisting raw capability tokens, use owner-only path-safe storage, and retain enough structured metadata for reconstruction and auditability. +- Remote and Cloud behavior: remote native services and Cloud attach flows continue to expose remote Caplets through the local Code Mode runtime; sessions do not imply durable server-side JavaScript state. +- Validation: Code Mode session work affects runtime tests, MCP/native/remote parity tests, CLI tests, generated declaration checks, docs checks, deterministic benchmarks, and opt-in live Pi eval reporting. + +--- + +## Implementation Units + +### U1. Public Contract And Metadata + +**Goal:** Add the session fields to Code Mode inputs, outputs, schemas, docs scaffolding, and tests without changing execution behavior yet. + +**Requirements:** R18, R20, plus the schema and metadata portions of R1, R3, and R5. Behavioral coverage for R1, R3, F1, and F4 belongs to U3. + +**Dependencies:** None. + +**Files:** + +- `packages/core/src/code-mode/tool.ts` +- `packages/core/src/code-mode/types.ts` +- `packages/core/src/code-mode/runtime-api.d.ts` +- `packages/core/src/code-mode/runtime-api.generated.ts` +- `packages/core/src/code-mode/declarations.ts` +- `packages/core/src/cli/code-mode.ts` +- `packages/core/test/code-mode-declarations.test.ts` +- `packages/core/test/code-mode-mcp.test.ts` +- `packages/core/test/native.test.ts` +- `packages/core/test/native-remote.test.ts` +- `packages/core/test/code-mode-cli.test.ts` + +**Approach:** Extend `codeModeRunInputSchema` and JSON schema with optional `sessionId`. Extend `CodeModeRunMeta` with `sessionId`, a creation/reuse indicator, and optional recovery pointer fields for failures. Keep `meta.sessionId` and agent-facing reuse guidance internal or test-only until U3's live session manager lands in the same release. Add CLI metadata/recovery scaffolding and long-lived REPL option parsing without implying that ordinary one-shot CLI invocations can reuse live heap across processes. + +**Patterns to follow:** `codeModeRunInputJsonSchema()` for native and remote schema parity; `CodeModeRunEnvelope` as the portable structured result; existing generated declaration checks in `code-mode-declarations.test.ts`. + +**Test scenarios:** + +- Contract path: metadata types and schemas can represent a session ID, creation/reuse status, and recovery metadata without making live reuse externally visible before U3. +- Happy path: a request with optional `sessionId` passes schema validation for MCP, native, remote, and CLI surfaces. +- Error path: invalid payloads still return `REQUEST_INVALID` and include the expanded metadata shape without throwing. +- Compatibility path: existing one-shot tests that ignore `sessionId` still pass without prompt or schema regressions. +- Docs prompt path: generated tool descriptions can represent session metadata without exposing agent-facing reuse guidance before U3 behavior lands. + +**Verification:** Contract tests prove all Code Mode surfaces accept the same input shape and preserve the current one-shot envelope, while public reuse guidance is gated until behavior lands with U3. + +### U2. Reusable QuickJS REPL Sandbox + +**Goal:** Refactor the sandbox so a live session can reuse a QuickJS runtime/context across calls while fresh sessions that are never reused keep the current Code Mode authoring and result behavior. + +**Requirements:** R2, R6, R8, R9, R10, F2, AE1, AE4. + +**Dependencies:** U1. + +**Files:** + +- `packages/core/src/code-mode/sandbox.ts` +- `packages/core/src/code-mode/diagnostics.ts` +- `packages/core/src/code-mode/platform-host.ts` +- `packages/core/src/code-mode/platform-runtime.generated.ts` +- `packages/core/src/code-mode/runner.ts` +- `packages/core/test/code-mode-diagnostics.test.ts` +- `packages/core/test/code-mode-session.test.ts` +- `packages/core/test/code-mode-runner.test.ts` +- `packages/core/test/code-mode-platform-api.test.ts` + +**Approach:** Split sandbox initialization from per-cell evaluation. A `QuickJsCodeModeReplSession` should install platform runtime, logging, debug, and invoke bridges once, then evaluate cells against the same context. The cell evaluator must preserve named helper functions and `var` state across calls while retaining Code Mode's existing `return` and top-level `await` authoring model. Diagnostics must become session-aware too: successful cells contribute ambient declarations or symbol metadata so later cells can typecheck references to prior helper functions and `var` bindings, and compatibility eviction clears that diagnostic state. Each session owns host timers/deferreds and disposes them on eviction or close. + +**Execution note:** Implement the REPL behavior test-first because small wrapper changes can appear correct while accidentally discarding helpers or leaking handles. + +**Technical design:** Directionally, keep three source phases separate: session initialization source, per-call bridge refresh source, and user cell source. Do not redeclare stable globals like `caplets` with lexical `const` on every reuse. + +**Patterns to follow:** Current `evaluateInQuickJs()` timeout, memory, stack, async drain, serialization, and platform host cleanup behavior; QuickJS docs on context isolation, runtime heaps, and manual handle disposal. + +**Test scenarios:** + +- Covers AE1. A first call defines a named helper and returns a value; a second call with the same `sessionId` can call that helper. +- Covers AE1. A first call initializes `var counter = 1`; a second call increments it and returns the updated value. +- Covers AE1. A second call that references a helper defined in a successful first call passes TypeScript diagnostics before sandbox execution. +- Covers R6. A new session cannot see helpers or variables from another session. +- Covers AE4. After a process restart, reusing a previous session ID does not restore closures, heap objects, timers, pending promises, or host handles. +- Error path: a timed-out cell does not leave pending timers or deferreds alive after eviction. +- Error path: a runtime error in one cell returns a structured error while leaving the session reusable for a later valid cell when the context remains healthy. +- Compatibility path: platform globals such as timers, `crypto.randomUUID`, and disabled `fetch` behave the same in one-shot and session runs. +- Resource path: disposing a session clears host timers and QuickJS handles without leaking into subsequent sessions. + +**Verification:** Focused sandbox and diagnostics tests demonstrate persistent helper state, session-aware typechecking, isolation, timeout cleanup, and unchanged platform API behavior. + +### U3. Session Manager, Scope, And Lifecycle + +**Goal:** Add the service-owned session registry that creates, reuses, evicts, and rejects Code Mode sessions consistently across MCP, native, remote composite, and long-lived CLI REPL entrypoints. + +**Requirements:** R1, R2, R3, R6, R7, R8, R10, R20, F1, F2, F4, AE2, AE6. Retained-journal recovery, recovery refs, and debug lookup behavior are owned by U4. + +**Dependencies:** U1, U2. + +**Files:** + +- `packages/core/src/code-mode/sessions.ts` +- `packages/core/src/code-mode/runner.ts` +- `packages/core/src/serve/session.ts` +- `packages/core/src/native/service.ts` +- `packages/core/src/native/remote.ts` +- `packages/core/src/cli/code-mode.ts` +- `packages/core/test/code-mode-session.test.ts` +- `packages/core/test/serve-session.test.ts` +- `packages/core/test/code-mode-mcp.test.ts` +- `packages/core/test/native.test.ts` +- `packages/core/test/native-remote.test.ts` +- `packages/core/test/code-mode-cli.test.ts` + +**Approach:** Introduce `CodeModeSessionManager` with injected clock, ID generator, TTL, live-session cap, scope key, journal hooks, and log store. Each Caplets MCP session, native service instance, remote composite, and long-lived CLI REPL owns one manager; ordinary one-shot CLI commands create and close a service for a single command and cannot reuse live heap across invocations. For V1, the scope key should be the owning runtime-service instance plus any stable host identity already available; when no stable identity exists, possession of the unguessable session ID authorizes only within that manager's scope. The manager serializes calls per session, lets unrelated sessions run independently, evicts by the KTD10 idle TTL and LRU cap, and rejects unknown IDs before sandbox execution. Compatibility keys should include declaration hash, platform runtime hash, runtime scope, and a code-mode session compatibility version. + +**Patterns to follow:** `CapletsMcpSession` lifecycle ownership, `DefaultNativeCapletsService.close()`, `RemoteNativeCapletsService` reset behavior, and Project Binding session tests that inject session IDs and lifecycle transitions. + +**Test scenarios:** + +- Covers AE1. MCP callback without `sessionId` creates a session and a later callback with that ID reuses state. +- Covers AE2. A supplied unknown `sessionId` fails before invoking sandbox or Caplet bridge code. +- Covers AE6. A session ID created under one manager scope is not reusable from another manager scope; when no stable host identity exists, possession authorizes reuse only within the originating manager scope. +- Edge path: concurrent calls to the same session execute in deterministic order or return a structured busy error; the chosen behavior is documented by the test. +- Lifecycle path: service close evicts live sessions and disposes QuickJS contexts. +- Compatibility path: changing callable Caplets or platform runtime hash evicts the session instead of continuing with stale bindings. +- Remote path: remote composite Code Mode sessions reuse local JavaScript state against the remote facade and do not depend on server-side heap continuity. +- CLI path: ordinary `caplets code-mode` remains one-shot, while `caplets code-mode repl` can reuse live state only inside the long-lived process. + +**Verification:** Session manager tests prove lifecycle and scope semantics without relying on real time or real filesystem state. + +### U4. Expiring Redacted Journal And Recovery Debug API + +**Goal:** Persist bounded session call history and expose scoped recovery helpers through `caplets.debug`. + +**Requirements:** R4, R11, R12, R13, R14, R15, R16, R17, R19, R21, F3, F5, AE3, AE5, AE7, AE8. + +**Dependencies:** U1, U3. + +**Files:** + +- `packages/core/src/code-mode/journal.ts` +- `packages/core/src/code-mode/logs.ts` +- `packages/core/src/code-mode/api.ts` +- `packages/core/src/code-mode/runtime-api.d.ts` +- `packages/core/src/code-mode/runtime-api.generated.ts` +- `packages/core/src/code-mode/types.ts` +- `packages/core/test/code-mode-journal.test.ts` +- `packages/core/test/code-mode-api.test.ts` +- `packages/core/test/code-mode-runner.test.ts` +- `packages/core/test/code-mode-declarations.test.ts` + +**Approach:** Add a session journal store under the existing Code Mode state directory. Store redacted submitted code, timestamps, non-capability journal keys or keyed hashes, declaration hashes, run outcome, diagnostic summaries, log refs, compatibility and eviction reasons, and recovery classification. Do not write raw `sessionId` or `recoveryRef` values to journal files, logs, diagnostics, benchmark reports, or recovery summaries; authorized lookup should use hashed comparison or in-memory indexes. Use bounded JSON storage per session with the KTD10 retention TTL, max entries, max code bytes per entry, and max summary bytes. Journal directories and files must be owner-only, written atomically or exclusively, reject symlink/path traversal attempts, and delete expired files. Extend the debug API with scoped helpers to read retained journal summaries by exact session/recovery capability and to list recent live sessions only when a stable host conversation/run identity scopes the request. + +**Recovery classification:** Mark entries setup-like only when the call is limited to declarations/global state setup and no external Caplet execution occurred. Mark calls with Caplet execution, host timers, or unknown behavior as side-effecting or unknown unless future metadata proves they were read-only. + +**Patterns to follow:** `CodeModeLogStore` directory layout, opaque refs, TTL behavior, pagination, and `redactCodeModeLogText`; existing `caplets.debug.readLogs()` API wiring. + +**Test scenarios:** + +- Covers AE3. Evicted session with retained journal produces an expired-session error containing a recovery ref that can be read through the debug helper. +- Covers AE5. Code, diagnostics, logs, and summaries containing tokens, emails, signed URLs, and high-entropy strings are redacted in persisted journal files. +- Covers AE5. Raw session IDs and recovery refs never appear in journal files, logs, diagnostics, benchmark reports, or recovery summaries. +- Covers AE5. Journal directories and files are owner-only, writes are exclusive or atomic, symlink/path traversal attempts are rejected, and expired files are deleted. +- Covers AE5. Journal retention caps trim old entries and reject oversized payloads without breaking the active run response. +- Covers AE6. A debug read from the wrong scope returns a structured denial or empty result without leaking whether unrelated session data exists. +- Covers AE7. A helper-definition call is rendered as setup-like, while a call that executes a Caplet tool is rendered as side-effecting or unknown. +- Covers AE8. Recent-session lookup returns only eligible live sessions for the current stable host identity; when no stable identity exists, lookup returns unsupported or empty and exact `sessionId` or `recoveryRef` possession is required. +- Error path: expired or malformed recovery refs return empty structured recovery results rather than throwing. + +**Verification:** Journal tests prove redaction, token non-persistence, owner-only path-safe storage, TTL, caps, scoped reads, and recovery classification independently from QuickJS execution. + +### U5. Surface Integration And Documentation + +**Goal:** Update public and internal docs so agents know when and how to reuse sessions, and keep generated references in sync. + +**Requirements:** R18, R19, R20, F1, F3, F5, AE1, AE2, AE3. + +**Dependencies:** U1, U3, U4. + +**Files:** + +- `apps/docs/src/content/docs/code-mode.mdx` +- `apps/docs/src/content/docs/reference/code-mode-api.mdx` +- `docs/product/caplets-code-mode-prd.md` +- `docs/architecture.md` +- `scripts/generate-docs-reference.ts` +- `packages/core/src/code-mode/runtime-api.d.ts` +- `packages/core/src/code-mode/runtime-api.generated.ts` +- `packages/core/test/code-mode-declarations.test.ts` +- `scripts/check-public-docs.ts` + +**Approach:** Document the session workflow in agent-facing terms: omit `sessionId` to start fresh, capture `meta.sessionId`, pass it to reuse, and inspect recovery history when an expired-session error provides a recovery ref. Explain that recent-session lookup is available only on surfaces with a stable host identity, and that CLI live reuse requires `caplets code-mode repl`; ordinary `caplets code-mode` invocations remain one-shot. Keep the reference page generated from runtime declarations and keep platform globals out of the prompt/declaration payload. + +**Patterns to follow:** Current Code Mode docs' compact examples, generated reference marker in `code-mode-api.mdx`, and docs check tokens in `check-public-docs.ts`. + +**Test scenarios:** + +- Happy path: generated docs reference includes the new debug recovery helpers and session metadata types. +- Happy path: public docs include an example showing `meta.sessionId` capture and reuse. +- Error path: docs describe unknown-session and expired-session recovery behavior without implying durable heap persistence. +- CLI path: docs do not imply that separate `caplets code-mode` invocations can share live heap state. +- Regression path: declaration tests continue proving platform globals are absent from the generated Code Mode declaration payload. + +**Verification:** Generated docs and public docs checks pass, and product docs align with the implemented contract. + +### U6. Pi Eval Repeated-Workflow Validation + +**Goal:** Add an eval path that can show whether session reuse reduces repeated setup code and overhead without weakening task success. + +**Requirements:** R22, AE9. + +**Dependencies:** U1, U3. + +**Files:** + +- `packages/benchmarks/lib/pi-eval/config.ts` +- `packages/benchmarks/lib/pi-eval/suites.ts` +- `packages/benchmarks/lib/pi-eval/metrics.ts` +- `packages/benchmarks/lib/pi-eval/report.ts` +- `packages/benchmarks/run-pi-eval.ts` +- `packages/benchmarks/fixtures/mcp-tool-use/tasks.json` +- `packages/benchmarks/fixtures/mcp-tool-use/mcp-server.ts` +- `packages/benchmarks/test/benchmark.test.ts` +- `packages/benchmarks/lib/code-mode.ts` +- `packages/benchmarks/test/code-mode-complex-workflow.test.ts` +- `docs/benchmarks/coding-agent.md` + +**Approach:** Add a repeated-workflow task or suite where the agent benefits from defining discovery/result-shaping helpers once and using them across adjacent Code Mode calls. Compare current one-shot Code Mode behavior against session-enabled reuse using task success, provider requests, tool-call count, token/request overhead, repeated setup-code volume, and elapsed time when stable. Add deterministic metric and report tests so the live eval has a stable reporting contract. + +**Patterns to follow:** Existing Pi eval modes, task suite selection, instrumentation metrics, report comparison tables, and deterministic Code Mode benchmark report generation. + +**Test scenarios:** + +- Covers AE9. Metrics summarization counts repeated setup-code volume for Code Mode runs where the same helper source appears across calls. +- Covers AE9. Report rendering includes repeated-workflow comparison fields without treating live results as deterministic release claims. +- Happy path: a repeated-workflow task can be selected through the Pi eval suite/task selection path. +- Regression path: existing Pi eval modes and comparisons still render when repeated-workflow metrics are absent. +- Deterministic path: Code Mode benchmark tests include a stable fixture or summary describing the session-reuse validation target. + +**Verification:** Benchmark unit tests cover the new metrics/report contract, and live Pi eval output can be interpreted alongside existing pass-rate gates. + +### U7. Release Hardening And Public API Guardrails + +**Goal:** Keep generated artifacts, public package boundaries, and release expectations aligned after the cross-cutting Code Mode change. + +**Requirements:** R7, R10, R13, R14, R15, R20, F1, F2, F3, F4, F5. + +**Dependencies:** U1, U2, U3, U4, U5, U6. + +**Files:** + +- `packages/core/src/code-mode/index.ts` +- `packages/core/test/code-mode-public-api.test.ts` +- `packages/core/package.json` +- `package.json` +- `pnpm-lock.yaml` +- `.changeset/*.md` +- `docs/adr/0001-code-mode-default-exposure.md` + +**Approach:** Preserve the pure `@caplets/core/code-mode` helper entrypoint, keep runtime-only exports out of the public subpath, and add a changeset because the Code Mode tool contract changes. Update ADR or architecture text only where the new session model changes the long-lived product contract. + +**Patterns to follow:** `code-mode-public-api.test.ts`, generated artifact checks, and the repo's existing changeset practice for user-facing package behavior. + +**Test scenarios:** + +- Regression path: public Code Mode helper imports remain pure and do not pull in QuickJS runtime, schemas, or service state. +- Regression path: generated Code Mode runtime API and docs references are in sync. +- Release path: package metadata and changeset reflect a user-facing Code Mode contract expansion. +- Security path: tests and docs do not claim Code Mode is a sandbox security boundary. + +**Verification:** Repo-level verification proves generated artifacts, docs, types, tests, benchmarks, and build output remain aligned. + +--- + +## Acceptance Examples + +- AE1. A successful fresh run returns `meta.sessionId`; a later adjacent run with that ID reuses the helper definitions created by the first run. +- AE2. A run with an unknown `sessionId` fails before sandbox execution and does not run user code that depends on prior variables. +- AE3. An evicted session with retained journal fails with an expired-session error and scoped recovery ref. +- AE4. A process restart never restores closures, heap objects, timers, pending promises, or host handles. +- AE5. Journal storage redacts obvious credentials and enforces retention and size caps. +- AE6. A different scope cannot reuse a live session or read its retained journal. +- AE7. Recovery output separates setup-like entries from side-effecting or unknown entries. +- AE8. Scoped recent-session lookup returns only eligible live sessions for the same stable host identity; without that identity, lookup returns unsupported or empty. +- AE9. Pi eval report output shows whether session reuse changes repeated setup-code volume, tool calls, request overhead, and task success. + +--- + +## Risks And Dependencies + +- **REPL compilation risk:** Preserving helper declarations while retaining `return` and top-level `await` may require a careful AST transform. Mitigation: lock behavior with U2 tests before broad integration. +- **QuickJS lifecycle risk:** Long-lived contexts increase the chance of leaked handles or timers. Mitigation: session-owned disposal, per-session caps, and timeout cleanup tests. +- **Security risk:** Session IDs and journal refs gate access to useful execution history. Mitigation: high-entropy IDs, scoped manager reads, redaction, and denial tests. +- **Storage risk:** Journals can accumulate sensitive or bulky source. Mitigation: redaction before write, no raw capability-token persistence, owner-only path-safe storage, entry caps, byte caps, and retention TTL. +- **Benchmark interpretation risk:** Live Pi eval output is model-dependent. Mitigation: use live eval for directional value and deterministic tests for report shape and metric availability. + +--- + +## Documentation And Operational Notes + +- Update Code Mode docs to frame sessions as an optional reuse affordance, not the default requirement for every run. +- Expired-session guidance should tell agents to inspect recovery history and reconstruct safe setup code manually. +- CLI docs should present ordinary `caplets code-mode` as one-shot and reserve live reuse for an explicit long-lived REPL flow. +- Doctor output can remain unchanged for V1 unless implementation exposes a health section for session/journal directories. + +--- + +## Sources And Research + +- Origin requirements: `docs/brainstorms/2026-06-17-code-mode-repl-sessions-requirements.md`. +- Product strategy: `STRATEGY.md`, especially Code Mode workflow efficiency and persistent workflow affordances. +- Current runtime: `packages/core/src/code-mode/runner.ts`, `packages/core/src/code-mode/sandbox.ts`, `packages/core/src/code-mode/platform-host.ts`, and `packages/core/src/code-mode/logs.ts`. +- Current surfaces: `packages/core/src/serve/session.ts`, `packages/core/src/native/service.ts`, `packages/core/src/native/remote.ts`, and `packages/core/src/cli/code-mode.ts`. +- Current docs and generated references: `docs/product/caplets-code-mode-prd.md`, `docs/architecture.md`, `apps/docs/src/content/docs/code-mode.mdx`, and `apps/docs/src/content/docs/reference/code-mode-api.mdx`. +- Current evals: `packages/benchmarks/lib/pi-eval/config.ts`, `packages/benchmarks/lib/pi-eval/suites.ts`, `packages/benchmarks/lib/pi-eval/metrics.ts`, `packages/benchmarks/lib/pi-eval/report.ts`, and `packages/benchmarks/lib/code-mode.ts`. +- Adjacent plan: `docs/plans/2026-06-17-code-mode-platform-api-compat.md`. +- QuickJS lifecycle guidance: [quickjs-emscripten runtime and context docs](https://github.com/justjake/quickjs-emscripten/blob/main/doc/README.md) and [QuickJSContext docs](https://github.com/justjake/quickjs-emscripten/blob/main/doc/quickjs-emscripten/classes/QuickJSContext.md). +- Session security guidance: [OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html). +- Logging sensitivity guidance: [OWASP Logging Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html). diff --git a/docs/plans/2026-06-18-001-feat-code-mode-repl-guidance-plan.md b/docs/plans/2026-06-18-001-feat-code-mode-repl-guidance-plan.md new file mode 100644 index 00000000..287f468a --- /dev/null +++ b/docs/plans/2026-06-18-001-feat-code-mode-repl-guidance-plan.md @@ -0,0 +1,272 @@ +--- +title: "feat: Teach Code Mode REPL reuse at the tool surface" +type: feat +date: 2026-06-18 +origin: docs/brainstorms/2026-06-17-code-mode-repl-sessions-requirements.md +--- + +# feat: Teach Code Mode REPL reuse at the tool surface + +## Summary + +Update the agent-facing Code Mode metadata so MCP, native, Pi, OpenCode, and remote attach surfaces teach the REPL reuse contract directly where agents inspect the tool. This plan does not change session runtime behavior; it makes the already-shipped `sessionId`, `meta.sessionId`, `sessionStatus`, and `recoveryRef` behavior obvious and tested. + +--- + +## Problem Frame + +Code Mode now supports reusable live sessions, but the tool description and input schema still describe `sessionId` generically. Agents can see that a field exists, yet they are not told when to omit it, when to pass it back, which bindings survive, what happens for unknown IDs, or how recovery metadata should be used. + +The requirements call out this gap directly: REPL reuse only creates value when agents can infer the workflow from tool metadata and native prompt guidance before they decide how to call Code Mode. + +--- + +## Requirements + +### Agent Guidance Contract + +- R1. The generated Code Mode tool description teaches agents to omit `sessionId` for a fresh reusable session, capture `meta.sessionId`, and pass it back for later reuse. +- R2. The description names the live-state boundary: successful top-level `var` bindings, function declarations, and runtime state survive only while the live session remains available and compatible. +- R3. The `sessionId` input schema description states that known IDs reuse existing REPL state and unknown or unavailable IDs fail before execution instead of starting an empty context. +- R4. Recovery guidance states that `recoveryRef` is for audit and manual reconstruction, not automatic replay. +- R5. One-shot usage remains simple: agents may omit `sessionId` when reuse is not useful. + +### Surface Parity + +- R6. MCP and local native Code Mode expose the same reuse guidance through the shared generated description and schema. +- R7. Remote attach and composite native Code Mode surfaces preserve the same schema wording and prompt guidance when Code Mode handles are advertised through remote manifests. +- R8. Pi tool definitions receive the updated description, prompt guidelines, and Code Mode input parameters without integration-specific rewrites. +- R9. OpenCode receives `sessionId` in its Code Mode args and includes the updated system/tool guidance. + +### Regression Coverage + +- R10. Tests assert the generated tool description, `sessionId` JSON schema description, native prompt guidance, Pi registration, OpenCode args, and remote attach Code Mode metadata. +- R11. Existing behavior tests for session creation, reuse, unknown-session failure, recovery metadata, and one-shot Code Mode remain behavioral source of truth. + +--- + +## Key Technical Decisions + +- KTD1. **Keep the guidance centralized in Core.** The shared Code Mode description and input schema live in `packages/core/src/code-mode/declarations.ts` and `packages/core/src/code-mode/tool.ts`; MCP, native, Pi, OpenCode, and remote surfaces should consume that wording instead of each package inventing its own contract. +- KTD2. **Add a small reusable prompt-guidance helper.** Native Code Mode prompt guidance should come from Core alongside the description so local native, remote composite, Pi, and OpenCode get the same short reuse instructions. +- KTD3. **Prefer contract tests over broad documentation churn.** The requirements explicitly target tool metadata and native prompt guidance, so the durable protection is in generated-description and integration tests. Human-facing docs can follow later if product docs need a separate editing pass. +- KTD4. **Keep guidance factual about best-effort live state.** The wording should not imply durable heap persistence. It should say reuse works while the live session remains available and compatible, and that starting fresh means omitting `sessionId`. +- KTD5. **Make OpenCode args honor the Core schema.** OpenCode currently hardcodes Code Mode args without `sessionId`; the plan should either add `sessionId` there or route through the same schema-conversion path used for JSON-schema-backed tools. + +--- + +## High-Level Technical Design + +```mermaid +flowchart TB + CoreDescription["Core Code Mode description helper"] --> MCP["MCP code_mode tool"] + CoreDescription --> Native["Local native caplets__code_mode"] + CoreDescription --> Remote["Remote attach Code Mode tool"] + Native --> Pi["Pi registered tool"] + Native --> OpenCode["OpenCode tool and system guidance"] + CoreSchema["Core Code Mode input schema"] --> MCP + CoreSchema --> Native + CoreSchema --> Remote + CoreSchema --> OpenCode +``` + +The design keeps the agent-facing contract in Core, then verifies that every integration receives it through its normal registration path. + +--- + +## Scope Boundaries + +### In Scope + +- Update generated Code Mode tool description wording. +- Update `sessionId` descriptions in Zod and JSON schemas. +- Update native system and prompt guidance for `caplets__code_mode`. +- Ensure remote attach Code Mode tools preserve the same schema and guidance. +- Ensure Pi and OpenCode expose the updated metadata and `sessionId` argument. +- Add focused regression tests for these surfaces. + +### Deferred to Follow-Up Work + +- Broad human-facing docs rewrites outside the exposed agent guidance. +- New reset, list, or recent-session lookup tools. +- Changes to session runtime behavior, TTL, journals, or durable heap persistence. +- New live eval tasks or benchmark result claims. + +--- + +## System-Wide Impact + +This change affects public agent-facing contracts without changing runtime semantics. Any model that reads the MCP tool definition, native tool metadata, Pi prompt guidelines, or OpenCode tool args should learn the same reuse workflow. The implementation also reduces future drift by making metadata tests fail when one integration loses `sessionId` or describes reuse differently. + +--- + +## Implementation Units + +### U1. Core Code Mode Reuse Wording + +**Goal:** Put the reusable-session contract in the shared Code Mode description and schema. + +**Requirements:** R1, R2, R3, R4, R5, R6, R10, R11. + +**Dependencies:** None. + +**Files:** + +- `packages/core/src/code-mode/declarations.ts` +- `packages/core/src/code-mode/tool.ts` +- `packages/core/test/code-mode-declarations.test.ts` +- `packages/core/test/code-mode-mcp.test.ts` + +**Approach:** Extend `generateCodeModeRunToolDescription()` with a concise REPL reuse paragraph before the generated declaration block. Update the Zod and JSON schema description for `sessionId` to state that known IDs reuse existing live REPL state and missing IDs fail. Keep the existing one-pass workflow guidance intact, but make clear that reusable sessions are optional and starting fresh means omitting `sessionId`. + +**Patterns to follow:** The current generated-description test already asserts key guidance phrases without snapshotting the full string. Keep that shape and add phrase-level assertions for `meta.sessionId`, `sessionStatus`, `recoveryRef`, unknown-session failure, and live-session state. + +**Test scenarios:** + +- Covers AE10. A generated Code Mode tool description includes the create, capture, reuse, unknown-failure, and recovery guidance in agent-facing language. +- Covers AE10. The MCP `code_mode` tool definition includes the new description and keeps generated declaration hints present. +- Covers AE10. The MCP input schema for `sessionId` says reuse requires a known live session and unknown IDs fail before execution. +- Regression path: one-shot wording remains present so agents are not told every Code Mode call must use sessions. + +**Verification:** The generated declaration and MCP tests prove the shared description and schema teach the reuse contract while preserving existing Code Mode guidance. + +### U2. Native And Remote Prompt Guidance + +**Goal:** Teach the same reuse workflow through native Code Mode prompt guidance and remote attach metadata. + +**Requirements:** R1, R3, R4, R5, R6, R7, R10. + +**Dependencies:** U1. + +**Files:** + +- `packages/core/src/native/tools.ts` +- `packages/core/src/native/service.ts` +- `packages/core/src/native/remote.ts` +- `packages/core/test/native.test.ts` +- `packages/core/test/native-remote.test.ts` + +**Approach:** Add a Core-owned Code Mode prompt-guidance helper that native system guidance and `codeModeRunNativeTool()` use. Apply equivalent guidance when `remoteToolToNativeTool()` maps an advertised remote Code Mode tool and when `toolsFromManifest()` synthesizes the attached Code Mode run tool. Ensure remote descriptions do not overwrite the richer Core description with a generic "Remote Caplets" sentence. + +**Patterns to follow:** Existing `nativeCapletsSystemGuidance()` and `codeModeRunNativeTool()` already centralize native copy. Existing native and remote tests inspect tool lists and schema properties without requiring live backend calls. + +**Test scenarios:** + +- Covers AE10. Local native `caplets__code_mode` prompt guidance tells agents to omit `sessionId` for fresh sessions and reuse `meta.sessionId`. +- Covers AE10. Native system guidance includes the REPL reuse contract for `caplets__code_mode`. +- Covers AE10. Remote attach Code Mode tools expose the same `sessionId` schema description and prompt guidance after manifest mapping. +- Regression path: remote composite Code Mode still scopes callable Caplets correctly and existing session reuse behavior remains covered by current native-remote tests. + +**Verification:** Native and native-remote tests prove local and attached native surfaces receive the same agent-facing contract from Core. + +### U3. Pi Integration Metadata + +**Goal:** Verify Pi receives the updated Code Mode description, prompt guidelines, and `sessionId` input parameter through normal native tool registration. + +**Requirements:** R8, R10. + +**Dependencies:** U2. + +**Files:** + +- `packages/pi/src/index.ts` +- `packages/pi/test/pi.test.ts` + +**Approach:** Prefer no Pi-specific implementation change if Core metadata flows through `createPiTool()` automatically. Add a focused test using a realistic Code Mode native tool object from Core-shaped metadata, then assert Pi registers the description, prompt guidelines, and parameters with the reuse contract intact. Only adjust `piToolSignature()` or `createPiTool()` if stale signature comparison or parameter handling drops the updated fields. + +**Patterns to follow:** `packages/pi/test/pi.test.ts` already captures registered tool definitions and asserts prompt guidelines, descriptions, and parameters. + +**Test scenarios:** + +- Covers AE10. A Pi-registered Code Mode tool exposes `sessionId` in parameters. +- Covers AE10. The Pi tool description and prompt guidelines include create, reuse, unknown-failure, and recovery guidance. +- Regression path: metadata updates still refresh an existing Pi tool definition when prompt guidance changes. + +**Verification:** Pi tests prove the integration surfaces Core metadata rather than losing reuse guidance at registration time. + +### U4. OpenCode Args And Guidance + +**Goal:** Ensure OpenCode exposes `sessionId` as a Code Mode argument and includes the updated reuse guidance in tool/system metadata. + +**Requirements:** R9, R10. + +**Dependencies:** U2. + +**Files:** + +- `packages/opencode/src/schema.ts` +- `packages/opencode/src/hooks.ts` +- `packages/opencode/README.md` +- `packages/opencode/test/opencode.test.ts` + +**Approach:** Add optional `sessionId` to `capletsOpenCodeRunArgs()` or route Code Mode args through the existing JSON-schema conversion path. Keep the tool description sourced from the native tool description. Add test assertions for the `sessionId` arg and system guidance, and update the package README only where it explains `caplets__code_mode` usage. + +**Patterns to follow:** Existing OpenCode tests inspect generated hook tools and transformed system guidance. The README already has a short Code Mode paragraph that can accept one sentence about optional reuse. + +**Test scenarios:** + +- Covers AE10. The OpenCode `caplets__code_mode` tool args include optional `sessionId`. +- Covers AE10. OpenCode tool description and system guidance include the reuse contract. +- Regression path: non-Code Mode direct and progressive tool args continue using their existing schema paths. +- Documentation path: the OpenCode README describes optional `sessionId` reuse without implying durable persistence. + +**Verification:** OpenCode tests and README formatting prove the integration exposes the same reuse affordance as MCP/native. + +### U5. Focused Verification And Drift Guard + +**Goal:** Run the smallest useful verification set for the metadata change and document any intentionally deferred broader checks. + +**Requirements:** R10, R11. + +**Dependencies:** U1, U2, U3, U4. + +**Files:** + +- `packages/core/test/code-mode-declarations.test.ts` +- `packages/core/test/code-mode-mcp.test.ts` +- `packages/core/test/native.test.ts` +- `packages/core/test/native-remote.test.ts` +- `packages/pi/test/pi.test.ts` +- `packages/opencode/test/opencode.test.ts` + +**Approach:** Keep the verification focused on metadata and integration registration because behavior tests already cover runtime reuse. Run package-level tests for Core, Pi, and OpenCode, and run formatting checks for changed docs/code. Do not run live Pi eval for this plan because no benchmark behavior changes. + +**Patterns to follow:** Repo guidance prefers focused package tests for package-scoped changes and treats live benchmarks as opt-in, local, model-dependent evidence. + +**Test scenarios:** + +- Happy path: all changed metadata surfaces contain the reuse contract. +- Edge path: no integration loses `sessionId` while converting schemas into host-specific tool args. +- Regression path: existing runtime behavior tests still prove create, reuse, unknown-session failure, and recovery metadata. +- Scope path: no tests or docs claim automatic journal replay or durable heap persistence. + +**Verification:** Focused Core, Pi, and OpenCode test files pass, and formatting checks pass for changed files. + +--- + +## Acceptance Examples + +- AE10. Given an agent inspects Code Mode through MCP, when it reads the tool description and schema, then it can infer how to start fresh, reuse `meta.sessionId`, and recover from missing live state. +- AE10. Given an agent uses Pi or OpenCode, when native prompt guidance is injected, then the guidance teaches the same reuse workflow as MCP. +- AE10. Given an agent has stale or unknown session state, when it reads the exposed guidance, then it expects failure before execution and uses recovery metadata only for audit or manual reconstruction. + +--- + +## Risks & Dependencies + +- **Overlong tool descriptions:** Code Mode descriptions already carry dense guidance and generated declarations. Keep the REPL paragraph short and test for important phrases rather than adding a long tutorial. +- **Integration drift:** OpenCode currently hardcodes Code Mode args, so it can miss new Core schema fields. U4 closes the immediate `sessionId` gap; future schema drift may warrant a shared conversion helper. +- **Overclaiming persistence:** Wording must say live session state is best-effort and process-local. Recovery metadata is not replay and not durable heap restore. +- **Remote ambiguity:** Remote attach Code Mode runs locally against remote handles. Guidance should describe Code Mode session reuse, not server-side Cloud heap continuity. + +--- + +## Sources & Research + +- `docs/brainstorms/2026-06-17-code-mode-repl-sessions-requirements.md` for the origin requirements, especially R18, R22-R25, R27, F6, and AE10. +- `docs/plans/2026-06-17-002-feat-code-mode-repl-sessions-plan.md` for the broader REPL-session implementation boundaries and existing behavior coverage. +- `docs/solutions/architecture-patterns/code-mode-repl-sessions.md` for the live-state plus recovery-journal architecture pattern. +- `CONCEPTS.md` for project vocabulary around Code Mode Sessions, Recovery Journals, and Recovery References. +- `packages/core/src/code-mode/declarations.ts` and `packages/core/src/code-mode/tool.ts` for the shared generated tool description and schema. +- `packages/core/src/native/tools.ts`, `packages/core/src/native/service.ts`, and `packages/core/src/native/remote.ts` for native and remote Code Mode metadata propagation. +- `packages/pi/src/index.ts` and `packages/opencode/src/schema.ts` / `packages/opencode/src/hooks.ts` for host-specific registration behavior. diff --git a/docs/plans/2026-06-18-code-mode-diagnostics-var-type-inference.md b/docs/plans/2026-06-18-code-mode-diagnostics-var-type-inference.md new file mode 100644 index 00000000..f08d5679 --- /dev/null +++ b/docs/plans/2026-06-18-code-mode-diagnostics-var-type-inference.md @@ -0,0 +1,573 @@ +# Code Mode Diagnostics Var Type Inference Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development or executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Preserve inferred TypeScript types for successful Code Mode REPL `var` bindings so later cells can typecheck reused mutable state without `unknown` warnings. + +**Architecture:** `CodeModeDiagnosticsSession` already stores ambient declarations for successful prior cells and feeds them into `diagnoseCodeModeTypeScript()`. Extend that session recorder so it builds a TypeScript program for the successful cell, asks the checker for each persisted function-scoped `var` binding type, and emits bounded ambient `declare var` declarations. Keep runtime behavior unchanged. + +**Tech Stack:** TypeScript compiler API, Vitest, pnpm, existing QuickJS Code Mode runtime. + +## Global Constraints + +- Use `pnpm` only. +- Keep changes scoped to Code Mode diagnostics; do not change QuickJS runtime persistence semantics. +- Only function-scoped `var` bindings persist across cells today; do not start recording `let` or `const`. +- Only successful cells update diagnostics session state. +- If inference is unavailable, too broad, too complex, or unsafe to serialize, fall back to `unknown`. +- Generated Code Mode API files must remain unchanged unless public runtime declarations change; this task should not require that. + +--- + +## File Structure + +- Modify: `packages/core/src/code-mode/diagnostics.ts` + - Add typed var declaration collection using TypeScript checker. + - Keep existing function declaration recording behavior. + - Replace `collectFunctionScopedVarNames()` / `collectVarDeclarationListNames()` with a typed binding collector. +- Modify: `packages/core/test/code-mode-session.test.ts` + - Add focused `CodeModeDiagnosticsSession` tests for inferred mutable `var` state. +- No docs required unless implementation exposes user-visible behavior beyond removing diagnostics warnings. + +--- + +### Task 1: Add Failing Diagnostics Tests For Persisted Var Types + +**Files:** + +- Modify: `packages/core/test/code-mode-session.test.ts` + +**Interfaces:** + +- Consumes: `CodeModeDiagnosticsSession.recordSuccessfulCell(code: string): void` +- Consumes: `diagnoseCodeModeTypeScript({ declaration, code, session })` +- Produces: Failing tests that define expected ambient type behavior for later implementation tasks. + +- [ ] **Step 1: Add primitive and mutation tests** + +Append these tests inside the existing `describe("CodeModeDiagnosticsSession", () => { ... })` block in `packages/core/test/code-mode-session.test.ts`: + +```ts +it("preserves inferred primitive var types for later session diagnostics", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell("var workflowRuns = 1;\nreturn workflowRuns;"); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: "workflowRuns += 1;\nreturn workflowRuns;", + session, + }); + + expect(diagnostics).toEqual([]); +}); + +it("preserves explicit var annotations for later session diagnostics", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell('var items: string[] = [];\nitems.push("a");\nreturn items;'); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: 'items.push("b");\nreturn items.join(",");', + session, + }); + + expect(diagnostics).toEqual([]); +}); +``` + +- [ ] **Step 2: Add object, array, destructuring, and redeclaration tests** + +Append these tests in the same describe block: + +```ts +it("preserves inferred object and array var types for later session diagnostics", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell( + 'var summary = { count: 1, label: "one" };\nvar numbers = [1, 2, 3];', + ); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: "summary.count += numbers[0] ?? 0;\nreturn `${summary.label}:${summary.count}`;", + session, + }); + + expect(diagnostics).toEqual([]); +}); + +it("preserves checker-inferred destructured var binding types when available", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell( + 'var { count, label } = { count: 1, label: "ready" };\nreturn label;', + ); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: "count += 1;\nreturn label.toUpperCase();", + session, + }); + + expect(diagnostics).toEqual([]); +}); + +it("updates var ambient types when successful cells redeclare a binding", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell("var mutable = 1;\nreturn mutable;"); + session.recordSuccessfulCell('var mutable = "ready";\nreturn mutable;'); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: "const numeric: number = mutable;\nreturn numeric;", + session, + }); + + expect(diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "2322", + message: expect.stringContaining("not assignable to type 'number'"), + }), + ]), + ); +}); +``` + +- [ ] **Step 3: Add failed-cell poisoning regression at runner level** + +Append this test in `packages/core/test/code-mode-sessions.test.ts` inside `describe("CodeModeSessionManager", () => { ... })`: + +```ts +it("does not record failed diagnostic cells into inferred session var types", async () => { + const manager = new CodeModeSessionManager({ idGenerator: () => "session-var-diagnostics" }); + try { + const first = await runCodeMode({ + code: "var counter = 1;\nreturn counter;", + service: service(), + sessionManager: manager, + runtimeScope: "test", + }); + const rejected = await runCodeMode({ + code: 'await caplets.github.call("listIssues", {});\nvar counter = "bad";', + service: service(), + sessionManager: manager, + sessionId: "session-var-diagnostics", + runtimeScope: "test", + }); + const reused = await runCodeMode({ + code: "counter += 1;\nreturn counter;", + service: service(), + sessionManager: manager, + sessionId: "session-var-diagnostics", + runtimeScope: "test", + }); + + expect(first).toMatchObject({ ok: true, value: 1 }); + expect(rejected).toMatchObject({ ok: false, error: { code: "diagnostic_blocked" } }); + expect(reused).toMatchObject({ ok: true, value: 2 }); + expect(reused.diagnostics).toEqual([]); + } finally { + manager.close(); + } +}); +``` + +- [ ] **Step 4: Run tests and verify failure** + +Run: + +```sh +pnpm --filter @caplets/core test -- test/code-mode-session.test.ts test/code-mode-sessions.test.ts +``` + +Expected before implementation: + +- The primitive/object/array/destructuring tests fail because `declare var : unknown` still causes strict diagnostics such as `'workflowRuns' is of type 'unknown'`. +- Existing tests continue to pass or fail only where they rely on the old unknown behavior. + +- [ ] **Step 5: Commit failing tests only** + +```sh +git add packages/core/test/code-mode-session.test.ts packages/core/test/code-mode-sessions.test.ts +git commit -m "test(code-mode): cover inferred session var diagnostics" +``` + +--- + +### Task 2: Infer Ambient Var Types With The TypeScript Checker + +**Files:** + +- Modify: `packages/core/src/code-mode/diagnostics.ts` + +**Interfaces:** + +- Produces: `collectFunctionScopedVarBindings(source, checker): Array<{ name: string; type: string }>` +- Produces: `safeAmbientType(type, checker): string` +- Preserves: `CodeModeDiagnosticsSession.recordSuccessfulCell(code: string): void` + +- [ ] **Step 1: Add a reusable compiler option helper** + +In `packages/core/src/code-mode/diagnostics.ts`, extract the compiler options currently built inline in `diagnoseCodeModeTypeScript()`: + +```ts +function codeModeCompilerOptions(): ts.CompilerOptions { + return { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + lib: ["lib.es2022.d.ts"], + types: [], + strict: true, + noEmit: true, + skipLibCheck: true, + noErrorTruncation: true, + allowJs: false, + }; +} +``` + +Then replace the existing inline object in `diagnoseCodeModeTypeScript()` with: + +```ts +const compilerOptions = codeModeCompilerOptions(); +``` + +- [ ] **Step 2: Build a checker for successful cells** + +Inside `CodeModeDiagnosticsSession.recordSuccessfulCell()`, after creating `source`, create a program that includes the successful cell and current session declarations: + +```ts +const compilerOptions = codeModeCompilerOptions(); +const host = createVirtualCompilerHost(compilerOptions, { + [CODE_FILE]: code, + [AMBIENT_FILE]: [CODE_MODE_DIAGNOSTICS_BUILTINS_DECLARATION, this.declaration()].join("\n"), +}); +const program = ts.createProgram([CODE_FILE, AMBIENT_FILE], compilerOptions, host); +const checker = program.getTypeChecker(); +const programSource = program.getSourceFile(CODE_FILE) ?? source; +``` + +Use `programSource` for all binding/type collection so nodes belong to the program used by the checker. + +- [ ] **Step 3: Replace name-only var collection with typed binding collection** + +Replace the current loop: + +```ts +for (const name of collectFunctionScopedVarNames(source)) { + this.#declarations.set(name, `declare var ${name}: unknown;`); +} +``` + +with: + +```ts +for (const binding of collectFunctionScopedVarBindings(programSource, checker)) { + this.#declarations.set(binding.name, `declare var ${binding.name}: ${binding.type};`); +} +``` + +Then replace `collectFunctionScopedVarNames()` and `collectVarDeclarationListNames()` with: + +```ts +type AmbientVarBinding = { + name: string; + type: string; +}; + +function collectFunctionScopedVarBindings( + source: ts.SourceFile, + checker: ts.TypeChecker, +): AmbientVarBinding[] { + const bindings = new Map(); + const visit = (node: ts.Node): void => { + if (node !== source && (ts.isFunctionLike(node) || ts.isClassLike(node))) { + return; + } + if (ts.isVariableStatement(node)) { + collectVarDeclarationListBindings(node.declarationList, checker, bindings); + } + if ( + (ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node)) && + node.initializer && + ts.isVariableDeclarationList(node.initializer) + ) { + collectVarDeclarationListBindings(node.initializer, checker, bindings); + } + ts.forEachChild(node, visit); + }; + visit(source); + return [...bindings.entries()].map(([name, type]) => ({ name, type })); +} + +function collectVarDeclarationListBindings( + declarationList: ts.VariableDeclarationList, + checker: ts.TypeChecker, + bindings: Map, +): void { + const isVar = (ts.getCombinedNodeFlags(declarationList) & ts.NodeFlags.BlockScoped) === 0; + if (!isVar) return; + for (const declaration of declarationList.declarations) { + for (const name of bindingNames(declaration.name)) { + const type = ambientTypeForBindingName(name, checker); + bindings.set(name.text, type); + } + } +} +``` + +- [ ] **Step 4: Add binding-name and type serialization helpers** + +Add these helpers near `bindingNames()`: + +```ts +function bindingNames(name: ts.BindingName): ts.Identifier[] { + if (ts.isIdentifier(name)) { + return [name]; + } + return name.elements.flatMap((element) => { + if (ts.isOmittedExpression(element)) { + return []; + } + return bindingNames(element.name); + }); +} + +function ambientTypeForBindingName(name: ts.Identifier, checker: ts.TypeChecker): string { + const type = checker.getTypeAtLocation(name); + return safeAmbientType(type, checker); +} + +function safeAmbientType(type: ts.Type, checker: ts.TypeChecker): string { + if (isUnsafeAmbientType(type)) return "unknown"; + const text = checker.typeToString( + type, + undefined, + ts.TypeFormatFlags.NoTruncation | + ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope | + ts.TypeFormatFlags.WriteArrayAsGenericType, + ); + if (!text || text === "any" || text === "{}" || text === "never") return "unknown"; + if (text.length > 500) return "unknown"; + if (/\bimport\(/u.test(text)) return "unknown"; + return text; +} + +function isUnsafeAmbientType(type: ts.Type): boolean { + return Boolean(type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)); +} +``` + +If TypeScript rejects `UseAliasDefinedOutsideCurrentScope` in this repo version, drop that flag and keep `NoTruncation | WriteArrayAsGenericType`. + +- [ ] **Step 5: Run focused tests** + +Run: + +```sh +pnpm --filter @caplets/core test -- test/code-mode-session.test.ts test/code-mode-sessions.test.ts +``` + +Expected: + +- New tests pass. +- Existing helper/var diagnostics tests still pass. + +- [ ] **Step 6: Commit implementation** + +```sh +git add packages/core/src/code-mode/diagnostics.ts +git commit -m "fix(code-mode): infer reused var diagnostics types" +``` + +--- + +### Task 3: Harden Type Output Against Noisy Or Invalid Ambient Declarations + +**Files:** + +- Modify: `packages/core/src/code-mode/diagnostics.ts` +- Modify: `packages/core/test/code-mode-session.test.ts` + +**Interfaces:** + +- Consumes: `safeAmbientType(type, checker): string` +- Produces: Stable bounded ambient declarations that do not leak huge anonymous types into every later diagnostics pass. + +- [ ] **Step 1: Add fallback tests for complex or unresolved types** + +Append these tests inside `describe("CodeModeDiagnosticsSession", () => { ... })`: + +```ts +it("falls back to unknown for unresolved or excessively complex var types", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell("var unresolved = JSON.parse('{\"value\":1}');\nreturn unresolved;"); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: "return unresolved.value;", + session, + }); + + expect(diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "18046", + message: expect.stringContaining("'unresolved' is of type 'unknown'"), + }), + ]), + ); +}); + +it("does not emit block-scoped let or const bindings into session diagnostics", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell( + "let localLet = 1;\nconst localConst = 2;\nreturn localLet + localConst;", + ); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: "return localLet + localConst;", + session, + }); + + expect(diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "2304", message: expect.stringContaining("localLet") }), + expect.objectContaining({ code: "2304", message: expect.stringContaining("localConst") }), + ]), + ); +}); +``` + +- [ ] **Step 2: Adjust `safeAmbientType()` for stable output** + +If tests expose noisy types, update `safeAmbientType()` with these exact guards: + +```ts +if (text.includes("__capletsCodeModeMain")) return "unknown"; +if (text.includes("/caplets-code-mode/")) return "unknown"; +if (/[{}]/u.test(text) && text.length > 200) return "unknown"; +``` + +Do not reject small object literals such as `{ count: number; label: string; }` because those are useful and expected by Task 1 tests. + +- [ ] **Step 3: Run focused diagnostics tests** + +Run: + +```sh +pnpm --filter @caplets/core test -- test/code-mode-session.test.ts +``` + +Expected: + +- All `CodeModeDiagnosticsSession` tests pass. + +- [ ] **Step 4: Commit hardening** + +```sh +git add packages/core/src/code-mode/diagnostics.ts packages/core/test/code-mode-session.test.ts +git commit -m "test(code-mode): harden inferred var diagnostics" +``` + +--- + +### Task 4: Run Cross-Surface Verification And Final Cleanup + +**Files:** + +- Modify only if verification exposes a bug: + - `packages/core/src/code-mode/diagnostics.ts` + - `packages/core/test/code-mode-session.test.ts` + - `packages/core/test/code-mode-sessions.test.ts` + +**Interfaces:** + +- Consumes: All prior task commits. +- Produces: Verified branch with no tracked dirty changes except intentional commits. + +- [ ] **Step 1: Run the focused Code Mode surface suite** + +Run: + +```sh +pnpm --filter @caplets/core test -- test/code-mode-session.test.ts test/code-mode-sessions.test.ts test/code-mode-runner.test.ts test/code-mode-mcp.test.ts test/native.test.ts test/native-remote.test.ts +``` + +Expected: + +- All selected tests pass. + +- [ ] **Step 2: Run static checks** + +Run: + +```sh +pnpm format:check +pnpm typecheck +pnpm code-mode:check-api +``` + +Expected: + +- `format:check` reports all matched files use correct format. +- `typecheck` succeeds for all packages. +- `code-mode:check-api` succeeds without generated API drift. + +- [ ] **Step 3: Run full verification** + +Run: + +```sh +pnpm verify +``` + +Expected: + +- Full verify passes: format, lint, Code Mode API check, schema check, docs check, typecheck, tests, benchmark check, and build. + +- [ ] **Step 4: Inspect final diff** + +Run: + +```sh +git status --short +git diff --stat HEAD~3..HEAD +git log --oneline -5 +``` + +Expected: + +- Only intended diagnostics/test commits are present. +- No generated files changed unexpectedly. +- No unrelated untracked files are staged. + +- [ ] **Step 5: Final review** + +Ask a reviewer to focus on: + +- whether inferred ambient types match persisted runtime `var` semantics, +- whether `let`/`const` remain excluded, +- whether complex types are bounded/fallback-safe, +- whether failed cells still do not update diagnostics state. + +Expected: + +- No blocker or important findings remain. + +--- + +## Self-Review + +- Spec coverage: The plan covers checker-based inference, persisted `var` state, explicit annotations, primitives, objects, arrays, destructuring, redeclaration, failed-cell non-poisoning, and verification. +- Placeholder scan: No `TBD`/`TODO` placeholders remain. +- Type consistency: Proposed helper signatures are defined before use and align with existing `CodeModeDiagnosticsSession.recordSuccessfulCell(code: string): void`. +- Scope check: This is a single subsystem change in diagnostics only; no runtime/session/journal behavior changes are planned. diff --git a/docs/product/caplets-code-mode-prd.md b/docs/product/caplets-code-mode-prd.md index efddcbbe..2a873765 100644 --- a/docs/product/caplets-code-mode-prd.md +++ b/docs/product/caplets-code-mode-prd.md @@ -88,6 +88,8 @@ Code Mode runs TypeScript with generated `caplets.` handles. Handles expose: - `callTool()` for execution. - MCP-only resource, template, prompt, and completion methods where supported. - `caplets.debug.readLogs()` for stored Code Mode log inspection when available. +- `caplets.debug.readRecovery()` for redacted, bounded recovery summaries when the agent + already has the session's `recoveryRef`. The runtime also provides common JavaScript platform globals for data manipulation: `atob`, `btoa`, a minimal `Buffer` subset, `structuredClone`, URL and text encoding @@ -99,6 +101,21 @@ generated declaration payload or tool prompt so Code Mode keeps its context surf Agents should keep bulky discovery and raw payload handling inside the Code Mode script, then return compact decision-ready JSON with the evidence fields needed by the user. +Code Mode runs accept an optional `sessionId`. Omitting it creates a fresh QuickJS session; +successful fresh and reused runs return `meta.sessionId` so adjacent calls can reuse live +helpers, variables, and cached discovery state. Unknown or expired session IDs fail before +submitted code runs with structured session errors. Session heap state is intentionally not +durable across process restarts or TTL eviction, and ordinary `caplets code-mode ...` CLI +invocations remain one-shot unless a separate long-lived REPL command is implemented. + +Recovery is reference-scoped, not broad lookup-based. Agents can read recovery history when +they possess the `recoveryRef` returned when the session was created, and a still-retained +journal may return that same reference when a known session ID was cleaned up by TTL, +compatibility eviction, or runtime restart while the retained journal remains readable. +Recovery summaries are redacted and bounded, and they help agents reconstruct setup code +manually. They do not restore heap, closures, timers, promises, or host handles. Unknown +session IDs do not upgrade into recovery references, and there is no recent-session lookup. + ## Exposure Modes The global default is `code_mode`. Per-Caplet exposure can override the default. diff --git a/docs/solutions/architecture-patterns/code-mode-repl-sessions.md b/docs/solutions/architecture-patterns/code-mode-repl-sessions.md new file mode 100644 index 00000000..5f8783a2 --- /dev/null +++ b/docs/solutions/architecture-patterns/code-mode-repl-sessions.md @@ -0,0 +1,130 @@ +--- +title: Code Mode REPL Sessions Use Live State Plus Recovery Journals +date: 2026-06-18 +category: architecture-patterns +module: Code Mode +problem_type: architecture_pattern +component: tooling +severity: medium +applies_when: + - "Adding reusable execution state to Code Mode or another agent-facing runtime" + - "A live runtime can disappear before the agent conversation or task context does" + - "Recovery must help agents reconstruct setup without restoring private heap state" +tags: [code-mode, sessions, recovery-journal, diagnostics, mcp] +--- + +# Code Mode REPL Sessions Use Live State Plus Recovery Journals + +## Context + +Code Mode started as a one-shot TypeScript execution surface: each call built declarations, ran diagnostics, created a fresh QuickJS sandbox, and returned one JSON-serializable result. That kept execution bounded, but it forced agents to repeat setup code, rediscover tool descriptors, and redefine helpers across adjacent calls. + +The solved design adds REPL-style reuse without promising durable heap persistence. Omitting `sessionId` creates a fresh live session and returns `meta.sessionId`; passing that ID reuses the same live QuickJS heap while it remains compatible and unexpired. When the heap is gone, Code Mode fails before execution and, when retained history exists for that known session, points the agent at a redacted recovery journal. Session history confirmed that this distinction was deliberate: durable heap snapshots were researched and rejected, while expiring call journals were chosen as the reconstruction and audit mechanism. + +## Guidance + +Separate three concepts that are easy to collapse into one: + +- Live session state: the in-process heap where variables, functions, and cached discovery results exist. +- Session identity: the short-lived handle agents pass back as `sessionId` to request reuse. +- Recovery history: a retained, redacted journal of setup-like calls that helps an agent reconstruct state after the live heap is gone. + +The runtime contract should be conservative: + +```ts +// First call creates reusable live state. +const first = await codeMode({ + code: "function summarize(tool) { return tool.name } return { ready: true }", +}); + +// Adjacent call reuses that state. +const second = await codeMode({ + sessionId: first.meta.sessionId, + code: "return summarize({ name: 'searchTools' })", +}); +``` + +Unknown, expired, or compatibility-mismatched sessions should not silently create a replacement under the requested ID. The submitted code probably depends on state that no longer exists, so the safe behavior is `SESSION_NOT_FOUND` before execution. A fresh session is requested by omitting `sessionId`. + +Recovery should be capability-scoped instead of lookup-scoped. Return a `recoveryRef` when a session is created and allow `caplets.debug.readRecovery()` only for callers that already possess that reference. If a known session ID is later cleaned up and its journal is still retained, the error metadata can return the same recovery reference. Unknown IDs must not become a way to enumerate or discover recovery history. + +Persist journal lookup metadata carefully: + +```ts +type StoredRecoveryJournal = { + sessionIdHash: string; + recoveryRefHashes: string[]; + entries: RedactedJournalEntry[]; +}; +``` + +Do not persist raw `sessionId` values or raw `recoveryRef` values. The final fix used keyed session hashes and deterministic recovery-reference derivation from a journal key plus secret, which let a fresh request-scoped journal store recover known retained sessions without turning the state directory into a credential store. + +Diagnostics must be session-aware too. A REPL that preserves `helper()` at runtime but runs TypeScript diagnostics against only the current cell will block or warn incorrectly on the next cell. Store ambient declarations for successful cells in the session manager, pass that diagnostics session into later checks, and record only successful cells so rejected code does not poison future diagnostics. + +## Why This Matters + +Agents need reusable workflows, not just reusable JavaScript heaps. The useful product behavior is "define the workflow once, call it repeatedly while nearby context is alive, and recover the setup steps if the runtime goes away." Trying to persist the full heap creates a harder security and correctness problem: closures, capabilities, host bridges, QuickJS version details, and private runtime objects do not have a stable, safe restore contract. + +The recovery journal also gives reviewable auditability. During final review, the highest-value findings all came from treating stale sessions as security and contract boundaries rather than convenience cases: + +- Expired sessions originally returned plain not-found errors even when retained journals existed. +- Compatibility invalidation risked running code in a new empty session under a stale ID. +- One-shot CLI execution accepted `--session-id` even though there was no long-lived CLI REPL process to reuse. +- Diagnostics had a session-state helper but the public runner did not use it. +- The first stale-session recovery fix worked only when the same journal store object was reused; MCP/native request paths construct fresh stores, so lookup had to be persisted by keyed metadata. + +Those findings are the pattern: every public surface must either reuse the exact live state or fail before execution with reconstructable context. Anything in between makes agents believe their workflow state exists when it does not. + +## When to Apply + +- Use this pattern when an agent-facing tool adds named reusable execution state. +- Use it when the agent conversation may outlive the process, MCP connection, or runtime TTL. +- Use it when repeated setup is valuable but durable heap snapshots would persist too much authority or implementation detail. +- Do not use session IDs as recovery credentials; keep recovery references separate and capability-scoped. + +## Examples + +Regression coverage should include the product contract, not just low-level session-manager behavior: + +```ts +it("rejects a stale session without running submitted code", async () => { + const created = await runCodeMode({ code: "var count = 1; return count" }); + const staleId = created.meta.sessionId; + + expireLiveSession(staleId); + + const reused = await runCodeMode({ + sessionId: staleId, + code: "count += 1; return count", + }); + + expect(reused.ok).toBe(false); + expect(reused.error.code).toBe("SESSION_NOT_FOUND"); + expect(sideEffectsFromSubmittedCode()).toEqual([]); +}); +``` + +```ts +it("uses successful prior cells for reused-session diagnostics", async () => { + const first = await runCodeMode({ + code: "function helper() { return 42 } return helper()", + }); + + const second = await runCodeMode({ + sessionId: first.meta.sessionId, + code: "return helper()", + }); + + expect(second.diagnostics).not.toContainEqual( + expect.objectContaining({ message: expect.stringContaining("Cannot find name 'helper'") }), + ); +}); +``` + +Session history also surfaced a later diagnostics edge: persisted `var` types must not serialize references to local-only types that later diagnostics cannot see. When recording ambient declarations from successful cells, validate that persisted declaration text is self-contained enough for future diagnostics, and prefer a safe fallback type over an invalid or misleading declaration. + +## Related + +- [Code Mode architecture](../../architecture.md#code-mode) +- [Caplets Code Mode PRD](../../product/caplets-code-mode-prd.md#code-mode-contract) diff --git a/packages/benchmarks/fixtures/mcp-tool-use/tasks.json b/packages/benchmarks/fixtures/mcp-tool-use/tasks.json index a006f85c..16b3819e 100644 --- a/packages/benchmarks/fixtures/mcp-tool-use/tasks.json +++ b/packages/benchmarks/fixtures/mcp-tool-use/tasks.json @@ -95,5 +95,50 @@ }, "distractorFacts": ["go", "REL-2026-06-10-search", "EXC-410 is valid"], "validator": "release-readiness-risk-report" + }, + { + "id": "repeated-release-gates", + "title": "Repeated release gate validation", + "prompt": "Evaluate two adjacent release gates by reusing any helper code or lookups that make the repeated workflow shorter. Return only the requested JSON final answer.", + "task_description": "Evaluate deploy readiness for two adjacent release records. Reuse helper setup when the selected tool surface supports it, and return whether each release can proceed, its blockers, and the shared policy threshold used.", + "fuzzy_description": "The agent benefits from defining helper logic once for loading deployment, quality, and policy inputs, then reusing that setup where the selected tool surface supports reuse instead of repeating boilerplate.", + "dependency_analysis": { + "servers": ["deployments", "quality", "policies"], + "requires": [ + "load adjacent deployment release records", + "inspect quality check details", + "apply the shared release policy", + "reuse helper setup across repeated gate evaluations" + ], + "dependency_pattern": "repeated-workflow" + }, + "repeatedWorkflow": { + "setupCodeMarkers": [ + "async function loadGateInputs", + "const summarizeGate =", + "const gateIds =" + ] + }, + "expectedEvidence": { + "servers": ["deployments", "quality", "policies"], + "tools": ["deployments.get_release", "quality.list_checks", "policies.get_release_policy"] + }, + "expectedFacts": { + "gateIds": ["REL-2026-06-10-payments", "REL-2026-06-10-search"], + "decisionByGate": { + "REL-2026-06-10-payments": "hold", + "REL-2026-06-10-search": "proceed" + }, + "blockersByGate": { + "REL-2026-06-10-payments": ["contract-tests", "rollback-plan"], + "REL-2026-06-10-search": [] + }, + "sharedPolicyThreshold": "risk score must not exceed 70, blocking checks must not fail, and rollback-plan warnings require a valid scoped exception" + }, + "distractorFacts": [ + "REL-2026-06-10-payments can proceed", + "policy threshold allows critical failures" + ], + "validator": "repeated-release-gates" } ] diff --git a/packages/benchmarks/lib/code-mode.ts b/packages/benchmarks/lib/code-mode.ts index 4ca2458c..2f4d47e2 100644 --- a/packages/benchmarks/lib/code-mode.ts +++ b/packages/benchmarks/lib/code-mode.ts @@ -39,6 +39,17 @@ export type ComplexWorkflowStrategyResult = { successScore: number; }; +export type RepeatedWorkflowStrategyResult = { + strategy: "progressive-disclosure" | "code-mode"; + providerRequests: number; + externalToolCalls: number; + setupCodeEstimatedTokens: number; + requestOverheadTokenProxy: number; + elapsedMs: number; + setupCodeReuseRate: number; + taskSuccess: boolean; +}; + export type CodeModeComplexWorkflowEval = { task: { id: string; @@ -65,6 +76,22 @@ export type CodeModeLiveRegressionEval = { improvements: string[]; }; +export type CodeModeRepeatedWorkflowEval = { + task: { + id: string; + description: string; + }; + strategies: RepeatedWorkflowStrategyResult[]; + reductions: { + setupCodeTokens: number; + providerRequests: number; + toolCalls: number; + requestOverheadTokenProxy: number; + elapsedMs: number; + }; + claim: string; +}; + export const CODE_MODE_BENCHMARK_TASKS: CodeModeBenchmarkTask[] = [ task( "single-list-filter", @@ -185,6 +212,11 @@ export const CODE_MODE_BENCHMARK_THRESHOLDS = { export const CODE_MODE_COMPLEX_WORKFLOW_THRESHOLDS = { minExternalCallReduction: 0.5, minSuccessScore: 0.9, + minRepeatedSetupTokenReduction: 0.4, + minRepeatedProviderRequestReduction: 0.4, + minRepeatedToolCallReduction: 0.4, + minRepeatedRequestOverheadReduction: 0.4, + minRepeatedElapsedTimeReduction: 0.4, } as const; const REQUIRED_LIVE_REGRESSION_IMPROVEMENTS = [ @@ -369,6 +401,114 @@ export function computeCodeModeLiveRegressionEval(): CodeModeLiveRegressionEval }; } +export function computeCodeModeRepeatedWorkflowEval(): CodeModeRepeatedWorkflowEval { + const strategies: RepeatedWorkflowStrategyResult[] = [ + repeatedStrategy({ + strategy: "progressive-disclosure", + providerRequests: 6, + externalToolCalls: 12, + setupCodeEstimatedTokens: 620, + requestOverheadTokenProxy: 980, + elapsedMs: 18_000, + setupCodeReuseRate: 0, + taskSuccess: true, + }), + repeatedStrategy({ + strategy: "code-mode", + providerRequests: 2, + externalToolCalls: 2, + setupCodeEstimatedTokens: 210, + requestOverheadTokenProxy: 340, + elapsedMs: 7_200, + setupCodeReuseRate: 0.5, + taskSuccess: true, + }), + ]; + const progressive = repeatedStrategyByName(strategies, "progressive-disclosure"); + const codeMode = repeatedStrategyByName(strategies, "code-mode"); + return { + task: { + id: "repeated-release-gates", + description: + "Evaluate adjacent release gates where helper setup can be defined once and reused across Code Mode calls.", + }, + strategies, + reductions: { + setupCodeTokens: reduction( + progressive.setupCodeEstimatedTokens, + codeMode.setupCodeEstimatedTokens, + ), + providerRequests: reduction(progressive.providerRequests, codeMode.providerRequests), + toolCalls: reduction(progressive.externalToolCalls, codeMode.externalToolCalls), + requestOverheadTokenProxy: reduction( + progressive.requestOverheadTokenProxy, + codeMode.requestOverheadTokenProxy, + ), + elapsedMs: reduction(progressive.elapsedMs, codeMode.elapsedMs), + }, + claim: + "This deterministic metric shape validates report dimensions for repeated setup-code volume, provider requests, tool calls, token overhead proxy, elapsed time, and task success; it is not a live model win-rate claim.", + }; +} + +export function validateCodeModeRepeatedWorkflowEval( + result: CodeModeRepeatedWorkflowEval, +): string[] { + const failures: string[] = []; + const codeMode = repeatedStrategyByName(result.strategies, "code-mode"); + const progressive = repeatedStrategyByName(result.strategies, "progressive-disclosure"); + if (!codeMode.taskSuccess || !progressive.taskSuccess) { + failures.push("Repeated workflow eval must keep task success true for both strategies."); + } + if (codeMode.setupCodeEstimatedTokens >= progressive.setupCodeEstimatedTokens) { + failures.push("Repeated workflow Code Mode setup-code tokens must be lower than baseline."); + } + if (codeMode.providerRequests >= progressive.providerRequests) { + failures.push("Repeated workflow Code Mode provider requests must be lower than baseline."); + } + if (codeMode.externalToolCalls >= progressive.externalToolCalls) { + failures.push("Repeated workflow Code Mode tool calls must be lower than baseline."); + } + if (codeMode.requestOverheadTokenProxy >= progressive.requestOverheadTokenProxy) { + failures.push( + "Repeated workflow Code Mode request-overhead proxy must be lower than baseline.", + ); + } + if (codeMode.elapsedMs >= progressive.elapsedMs) { + failures.push("Repeated workflow Code Mode elapsed time must be lower than baseline."); + } + if ( + result.reductions.setupCodeTokens < + CODE_MODE_COMPLEX_WORKFLOW_THRESHOLDS.minRepeatedSetupTokenReduction + ) { + failures.push("Repeated workflow setup-code token reduction is below threshold."); + } + if ( + result.reductions.providerRequests < + CODE_MODE_COMPLEX_WORKFLOW_THRESHOLDS.minRepeatedProviderRequestReduction + ) { + failures.push("Repeated workflow provider request reduction is below threshold."); + } + if ( + result.reductions.toolCalls < CODE_MODE_COMPLEX_WORKFLOW_THRESHOLDS.minRepeatedToolCallReduction + ) { + failures.push("Repeated workflow tool-call reduction is below threshold."); + } + if ( + result.reductions.requestOverheadTokenProxy < + CODE_MODE_COMPLEX_WORKFLOW_THRESHOLDS.minRepeatedRequestOverheadReduction + ) { + failures.push("Repeated workflow request-overhead proxy reduction is below threshold."); + } + if ( + result.reductions.elapsedMs < + CODE_MODE_COMPLEX_WORKFLOW_THRESHOLDS.minRepeatedElapsedTimeReduction + ) { + failures.push("Repeated workflow elapsed-time reduction is below threshold."); + } + return failures; +} + export function validateCodeModeLiveRegressionEval(result: CodeModeLiveRegressionEval): string[] { const failures: string[] = []; for (const required of REQUIRED_LIVE_REGRESSION_IMPROVEMENTS) { @@ -388,9 +528,12 @@ export function renderCodeModeMarkdownReport(): string { const benchmark = computeCodeModeBenchmark(); const complex = computeCodeModeComplexWorkflowEval(); const liveRegressions = computeCodeModeLiveRegressionEval(); + const repeated = computeCodeModeRepeatedWorkflowEval(); const codeMode = strategyByName(complex.strategies, "code-mode"); const progressive = strategyByName(complex.strategies, "progressive-disclosure"); const vanilla = strategyByName(complex.strategies, "vanilla-mcp"); + const repeatedCodeMode = repeatedStrategyByName(repeated.strategies, "code-mode"); + const repeatedProgressive = repeatedStrategyByName(repeated.strategies, "progressive-disclosure"); return `## Code Mode Workflow Eval The deterministic Code Mode fixture covers ${benchmark.tasks.length} PRD task categories and shows ${percent(benchmark.totals.roundTripReduction)} fewer model/tool round trips versus equivalent progressive-disclosure sequences, with ${percent(benchmark.totals.contextTokenReduction)} lower approximate context tokens. @@ -407,6 +550,17 @@ Task: ${complex.task.description} Code Mode preserves required triage fields (${complex.task.requiredFields.map((field) => `\`${field}\``).join(", ")}) while reducing external calls versus progressive disclosure by ${percent(complex.reductions.codeModeVsProgressiveExternalCalls)} and approximate payload tokens by ${percent(complex.reductions.codeModeVsProgressivePayloadTokens)}. +### Repeated Workflow Session Reuse + +Task: ${repeated.task.description} + +| Strategy | Provider requests | Tool calls | Setup-code tokens | Request overhead proxy | Elapsed time | Setup reuse rate | Task success | +| ---------------------- | ----------------: | ---------: | ----------------: | ---------------------: | -----------: | ---------------: | ------------ | +| Progressive disclosure | ${repeatedProgressive.providerRequests} | ${repeatedProgressive.externalToolCalls} | ${repeatedProgressive.setupCodeEstimatedTokens} | ${repeatedProgressive.requestOverheadTokenProxy} | ${repeatedProgressive.elapsedMs}ms | ${percent(repeatedProgressive.setupCodeReuseRate)} | ${repeatedProgressive.taskSuccess ? "yes" : "no"} | +| Code Mode | ${repeatedCodeMode.providerRequests} | ${repeatedCodeMode.externalToolCalls} | ${repeatedCodeMode.setupCodeEstimatedTokens} | ${repeatedCodeMode.requestOverheadTokenProxy} | ${repeatedCodeMode.elapsedMs}ms | ${percent(repeatedCodeMode.setupCodeReuseRate)} | ${repeatedCodeMode.taskSuccess ? "yes" : "no"} | + +${repeated.claim} In this stable fixture, Code Mode reduces repeated setup-code tokens by ${percent(repeated.reductions.setupCodeTokens)}, provider requests by ${percent(repeated.reductions.providerRequests)}, tool calls by ${percent(repeated.reductions.toolCalls)}, request overhead proxy by ${percent(repeated.reductions.requestOverheadTokenProxy)}, and elapsed time by ${percent(repeated.reductions.elapsedMs)} while preserving task success. + ### Live Regression Guardrails The deterministic report also records live cold-agent failure classes without treating model-dependent runs as deterministic claims. Current guardrails: ${liveRegressions.improvements.map((improvement) => `\`${improvement}\``).join(", ")}. @@ -449,6 +603,19 @@ function strategy( }; } +function repeatedStrategy(input: RepeatedWorkflowStrategyResult): RepeatedWorkflowStrategyResult { + return input; +} + +function repeatedStrategyByName( + strategies: RepeatedWorkflowStrategyResult[], + name: RepeatedWorkflowStrategyResult["strategy"], +): RepeatedWorkflowStrategyResult { + const result = strategies.find((strategy) => strategy.strategy === name); + if (!result) throw new Error(`Missing repeated workflow strategy ${name}`); + return result; +} + function strategyByName( strategies: ComplexWorkflowStrategyResult[], name: ComplexWorkflowStrategyResult["strategy"], diff --git a/packages/benchmarks/lib/pi-eval/metrics.ts b/packages/benchmarks/lib/pi-eval/metrics.ts index 16eac855..bd634a85 100644 --- a/packages/benchmarks/lib/pi-eval/metrics.ts +++ b/packages/benchmarks/lib/pi-eval/metrics.ts @@ -54,9 +54,7 @@ export function summarizePiEvalMetrics( const jsonToolStartEvents = jsonEvents.filter( (event) => String(event.type ?? event.event ?? "") === "tool_execution_start", ); - const toolStartEvents = instrumentedToolStartEvents.length - ? instrumentedToolStartEvents - : jsonToolStartEvents; + const toolStartEvents = mergeToolStartEvents(instrumentedToolStartEvents, jsonToolStartEvents); const toolCallNames = toolStartEvents.map(toolNameFromEvent).filter(Boolean) as string[]; const hybridChoice = classifyHybridChoice(toolCallNames, options); const directToolsPrewarmFailure = @@ -70,6 +68,7 @@ export function summarizePiEvalMetrics( providerRequests.map((event) => event.toolSurfaceEstimatedTokens), ); const requestTokenBuckets = summarizeRequestTokenBuckets(providerRequests); + const repeatedWorkflow = summarizeRepeatedWorkflow(toolStartEvents); return { tokenizer: TOKENIZER_INFO, providerRequestCount: providerRequests.length, @@ -94,11 +93,146 @@ export function summarizePiEvalMetrics( providerRequests.map((event) => event.messagePayloadEstimatedTokens), ), requestTokenBuckets, + repeatedWorkflow: repeatedWorkflow.codeModeCallCount > 0 ? repeatedWorkflow : null, providerUsage: mergeUsage(events, jsonEvents), resolvedModel: latestRequest?.model ?? null, }; } +const DEFAULT_SETUP_CODE_MARKERS = [ + "function ", + "async function ", + "const ", + "let ", + "var ", + "class ", + "=>", +] as const; + +export function summarizeRepeatedWorkflow(toolStartEvents: any[] = []) { + const codeModeEvents = toolStartEvents.filter( + (event) => toolNameFromEvent(event) === "caplets__code_mode", + ); + const codeSnippets = codeModeEvents.map(codeFromToolEvent).filter(Boolean) as string[]; + const sessionReuseCallCount = countSessionReuseCalls(codeModeEvents); + const setupSnippets = codeSnippets.filter(hasSetupCodeMarker); + const normalizedCounts = new Map(); + for (const snippet of setupSnippets) { + const normalized = normalizeSetupCode(snippet); + normalizedCounts.set(normalized, (normalizedCounts.get(normalized) ?? 0) + 1); + } + const repeatedSetupCodeCallCount = [...normalizedCounts.values()].reduce( + (total, count) => total + Math.max(0, count - 1), + 0, + ); + const repeatedSetupCode = repeatedSetupSnippets(setupSnippets).join("\n\n"); + const setupCode = setupSnippets.join("\n\n"); + const repeatedSetupCodeEstimatedTokens = + estimateTokens(repeatedSetupCode) ?? byteTokenProxy(repeatedSetupCode); + return { + codeModeCallCount: codeModeEvents.length, + sessionReuseCallCount, + setupCodeCallCount: setupSnippets.length, + repeatedSetupCodeCallCount, + setupCodeBytes: Buffer.byteLength(setupCode, "utf8"), + setupCodeEstimatedTokens: estimateTokens(setupCode) ?? byteTokenProxy(setupCode), + repeatedSetupCodeBytes: Buffer.byteLength(repeatedSetupCode, "utf8"), + repeatedSetupCodeEstimatedTokens, + setupCodeMarkers: matchedSetupCodeMarkers(setupSnippets), + setupCodeReuseRate: + codeModeEvents.length > 1 ? sessionReuseCallCount / (codeModeEvents.length - 1) : 0, + }; +} + +function codeFromToolEvent(event: any): string | null { + const input = inputFromToolEvent(event); + if (typeof input === "string") return input; + if (!input || typeof input !== "object") return null; + const code = input.code ?? input.script ?? input.source ?? input.program; + return typeof code === "string" ? code : null; +} + +function sessionIdFromToolEvent(event: any): string | null { + const input = inputFromToolEvent(event); + if (!input || typeof input !== "object") return null; + if (typeof input.sessionId !== "string") return null; + const value = input.sessionId.trim(); + return value.length > 0 ? value : null; +} + +function countSessionReuseCalls(codeModeEvents: any[]): number { + let count = 0; + for (const [index, event] of codeModeEvents.entries()) { + const sessionId = sessionIdFromToolEvent(event); + if (!sessionId) continue; + if (index > 0) { + count += 1; + } + } + return count; +} + +function mergeToolStartEvents(instrumented: any[], jsonEvents: any[]): any[] { + if (instrumented.length === 0) return jsonEvents; + if (jsonEvents.length === 0) return instrumented; + const jsonByToolName = new Map(); + for (const event of jsonEvents) { + const name = toolNameFromEvent(event); + if (!name) continue; + const events = jsonByToolName.get(name) ?? []; + events.push(event); + jsonByToolName.set(name, events); + } + return instrumented.map((event) => { + if (inputFromToolEvent(event) !== undefined) return event; + const name = toolNameFromEvent(event); + if (!name) return event; + const replacement = jsonByToolName.get(name)?.shift(); + return replacement && inputFromToolEvent(replacement) !== undefined ? replacement : event; + }); +} + +function inputFromToolEvent(event: any): any { + return event?.input ?? event?.args ?? event?.params ?? event?.toolInput; +} + +function hasSetupCodeMarker(code: string): boolean { + const markerCount = DEFAULT_SETUP_CODE_MARKERS.filter((marker) => code.includes(marker)).length; + return ( + /\b(?:async\s+)?function\s+[$\w]+/u.test(code) || + /\bclass\s+[$\w]+/u.test(code) || + markerCount >= 2 + ); +} + +function matchedSetupCodeMarkers(snippets: string[]): string[] { + const text = snippets.join("\n"); + const markers = new Set(); + for (const match of text.matchAll(/\basync function\s+[$\w]+/gu)) markers.add(match[0]); + for (const match of text.matchAll(/(?(); + const repeated: string[] = []; + for (const snippet of setupSnippets) { + const normalized = normalizeSetupCode(snippet); + const count = seen.get(normalized) ?? 0; + if (count > 0) repeated.push(snippet); + seen.set(normalized, count + 1); + } + return repeated; +} + export function summarizeRequestTokenBuckets(providerRequests: any[] = []) { const buckets = [ "requestPayloadEstimatedTokens", diff --git a/packages/benchmarks/lib/pi-eval/report.ts b/packages/benchmarks/lib/pi-eval/report.ts index e598b9f3..fbe60b77 100644 --- a/packages/benchmarks/lib/pi-eval/report.ts +++ b/packages/benchmarks/lib/pi-eval/report.ts @@ -27,6 +27,12 @@ export function summarizePiEvalResults(results: any[] = []) { averageToolSurfaceEstimatedTokens: average( rows.map((row) => row.metrics?.toolSurfaceEstimatedTokens), ), + averageRepeatedSetupCodeEstimatedTokens: average( + rows.map((row) => row.metrics?.repeatedWorkflow?.repeatedSetupCodeEstimatedTokens), + ), + averageSetupCodeReuseRate: averageRaw( + rows.map((row) => row.metrics?.repeatedWorkflow?.setupCodeReuseRate), + ), averageRequestTokenBuckets: averageRequestTokenBuckets(rows), averageToolCalls: average( rows.map((row) => row.metrics?.toolCallCount ?? row.score?.metrics?.toolCallCount), @@ -47,9 +53,13 @@ export function renderPiEvalMarkdownReport(report: any): string { (row: any) => `| ${row.mode} | ${row.product ?? "n/a"} | ${row.adapterExposure ?? "n/a"} | ${row.passed}/${row.total} | ${formatMs(row.averageDurationMs)} | ${formatNumber(row.averageProviderRequestCount)} | ${formatNumber(row.averageRequestEstimatedTokens)} | ${formatNumber(row.averageEstimatedOutputTokens)} | ${formatNumber(row.averageRequestPlusOutputEstimatedTokens)} | ${formatNumber(row.passedOnly?.averageRequestPlusOutputEstimatedTokens)} | ${formatNumber(row.averageNonSurfaceEstimatedTokens)} | ${formatNumber(row.averageProviderTokens)} | ${formatNumber(row.passedOnly?.averageProviderTokens)} | ${formatNumber(row.averageToolSurfaceEstimatedTokens)} | ${formatNumber(row.averageToolCalls)} |`, ); + const repeatedRows = report.summary.byMode.map( + (row: any) => + `| ${row.mode} | ${formatNumber(row.averageRepeatedSetupCodeEstimatedTokens)} | ${formatPercent(row.averageSetupCodeReuseRate)} | ${formatNumber(row.averageProviderRequestCount)} | ${formatNumber(row.averageToolCalls)} | ${row.passed}/${row.total} |`, + ); const comparisonRows = report.summary.comparisons.map( (comparison: any) => - `- ${comparison.label}: duration ${formatPercent(comparison.durationReduction)}, LLM round trips ${formatPercent(comparison.providerRequestReduction)}, estimated request tokens ${formatPercent(comparison.requestTokenReduction)}, request+output tokens ${formatPercent(comparison.requestPlusOutputTokenReduction)}, provider tokens ${formatPercent(comparison.providerTokenReduction)}`, + `- ${comparison.label}: duration ${formatPercent(comparison.durationReduction)}, LLM round trips ${formatPercent(comparison.providerRequestReduction)}, estimated request tokens ${formatPercent(comparison.requestTokenReduction)}, request+output tokens ${formatPercent(comparison.requestPlusOutputTokenReduction)}, setup-code tokens ${formatPercent(comparison.repeatedSetupTokenReduction)}, provider tokens ${formatPercent(comparison.providerTokenReduction)}`, ); const publishabilityRows = report.summary.comparisons.map( (comparison: any) => @@ -86,6 +96,14 @@ export function renderPiEvalMarkdownReport(report: any): string { "| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |", ...rows, "", + "## Repeated Workflow Reuse", + "", + "Average repeated setup-code tokens are measured from Code Mode run inputs when present. Lower setup-code volume with unchanged task success is evidence of session reuse reducing repeated workflow overhead; live win rates remain model-dependent.", + "", + "| Mode | Avg repeated setup-code tokens | Avg setup-code reuse rate | Avg LLM round trips | Avg tool calls | Passed |", + "| --- | ---: | ---: | ---: | ---: | ---: |", + ...repeatedRows, + "", "## Token Bucket Breakdown", "", "Average tokenizer-estimated request tokens per run, split by payload bucket. These buckets explain why total tokens can be close even when tool surfaces differ.", @@ -180,6 +198,10 @@ function compareRows(a: any, b: any, label: any) { b.averageRequestPlusOutputEstimatedTokens, a.averageRequestPlusOutputEstimatedTokens, ); + const repeatedSetupTokenReduction = reduction( + b.averageRepeatedSetupCodeEstimatedTokens, + a.averageRepeatedSetupCodeEstimatedTokens, + ); const providerTokenReduction = reduction(b.averageProviderTokens, a.averageProviderTokens); return { label, @@ -193,6 +215,7 @@ function compareRows(a: any, b: any, label: any) { a.averageRequestEstimatedTokens, ), requestPlusOutputTokenReduction, + repeatedSetupTokenReduction, providerTokenReduction, passRateDelta, passRateComparable, @@ -292,6 +315,12 @@ function average(values: any[]): number | null { return Math.round(nums.reduce((sum, value) => sum + value, 0) / nums.length); } +function averageRaw(values: any[]): number | null { + const nums = values.filter((value) => typeof value === "number" && Number.isFinite(value)); + if (!nums.length) return null; + return nums.reduce((sum, value) => sum + value, 0) / nums.length; +} + function formatMs(value: any) { return typeof value === "number" ? `${Math.round(value)}ms` : "n/a"; } diff --git a/packages/benchmarks/lib/pi-eval/suites.ts b/packages/benchmarks/lib/pi-eval/suites.ts index ae73a965..a163464b 100644 --- a/packages/benchmarks/lib/pi-eval/suites.ts +++ b/packages/benchmarks/lib/pi-eval/suites.ts @@ -76,6 +76,7 @@ function publicMcpToolUseTaskMetadata(task: any) { fuzzy_description: task.fuzzy_description ?? null, dependency_analysis: task.dependency_analysis ?? null, expectedEvidence: task.expectedEvidence ?? null, + repeatedWorkflow: task.repeatedWorkflow ?? null, validator: task.validator ?? null, }; } @@ -206,6 +207,7 @@ function buildMcpToolUsePrompt(task: any, mode: string): string { "You are running a benchmark. Complete the backend tool-use task using the configured MCP tools.", "Do not inspect benchmark harness files. Do not edit task files.", "Use tool evidence for every material fact. Do not guess.", + repeatedWorkflowHint(task, mode), "Return a concise final answer containing one JSON object with keys: taskId, decision, facts, summary.", "Each facts entry must include key, value, and evidence.", mcpToolUseModeHint(mode), @@ -234,3 +236,11 @@ function mcpToolUseModeHint(mode: string) { }; return hints[mode] ?? ""; } + +function repeatedWorkflowHint(task: any, mode: string) { + if (!task.repeatedWorkflow) return null; + if (mode.includes("code-mode")) { + return "This is a repeated workflow: define reusable helper logic once when useful and reuse it across adjacent Code Mode calls instead of repeating setup code."; + } + return "This is a repeated workflow: avoid repeating setup work where your available tools support reuse, but do not assume Code Mode is available unless this mode exposes it."; +} diff --git a/packages/benchmarks/run-deterministic.ts b/packages/benchmarks/run-deterministic.ts index f5e870be..2fda16be 100644 --- a/packages/benchmarks/run-deterministic.ts +++ b/packages/benchmarks/run-deterministic.ts @@ -1,6 +1,8 @@ #!/usr/bin/env node -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { computeSurfaceBenchmark, @@ -11,10 +13,12 @@ import { computeCodeModeBenchmark, computeCodeModeComplexWorkflowEval, computeCodeModeLiveRegressionEval, + computeCodeModeRepeatedWorkflowEval, renderCodeModeMarkdownReport, validateCodeModeBenchmark, validateCodeModeComplexWorkflowEval, validateCodeModeLiveRegressionEval, + validateCodeModeRepeatedWorkflowEval, } from "./lib/code-mode"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -26,11 +30,13 @@ const result = await computeSurfaceBenchmark(); const codeModeResult = computeCodeModeBenchmark(); const complexWorkflowResult = computeCodeModeComplexWorkflowEval(); const liveRegressionResult = computeCodeModeLiveRegressionEval(); +const repeatedWorkflowResult = computeCodeModeRepeatedWorkflowEval(); const failures = [ ...validateSurfaceBenchmark(result), ...validateCodeModeBenchmark(codeModeResult), ...validateCodeModeComplexWorkflowEval(complexWorkflowResult), ...validateCodeModeLiveRegressionEval(liveRegressionResult), + ...validateCodeModeRepeatedWorkflowEval(repeatedWorkflowResult), ]; if (failures.length > 0) { for (const failure of failures) { @@ -39,7 +45,7 @@ if (failures.length > 0) { process.exit(1); } -const markdown = renderMarkdownReport(result, renderCodeModeMarkdownReport()); +const markdown = formatMarkdown(renderMarkdownReport(result, renderCodeModeMarkdownReport())); if (checkMode) { let current; @@ -60,3 +66,15 @@ if (checkMode) { writeFileSync(reportPath, markdown, "utf8"); console.log(`Wrote ${reportPath}`); } + +function formatMarkdown(value: string): string { + const dir = mkdtempSync(join(tmpdir(), "caplets-benchmark-docs-")); + const path = join(dir, "coding-agent.md"); + try { + writeFileSync(path, value); + execFileSync("pnpm", ["exec", "oxfmt", path], { stdio: "inherit" }); + return readFileSync(path, "utf8"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} diff --git a/packages/benchmarks/test/benchmark.test.ts b/packages/benchmarks/test/benchmark.test.ts index 0273f293..b551dd5f 100644 --- a/packages/benchmarks/test/benchmark.test.ts +++ b/packages/benchmarks/test/benchmark.test.ts @@ -1318,6 +1318,12 @@ describe("Pi live tool surface eval harness", () => { "release-readiness-risk-report", ], }); + expect( + parsePiEvalArgs(["--task-suite", "mcp-tool-use", "--tasks", "repeated-release-gates"]), + ).toMatchObject({ + taskSuite: "mcp-tool-use", + tasks: ["repeated-release-gates"], + }); expect(parsePiEvalArgs(["--task-suite", "mcp-realistic-noauth"])).toMatchObject({ taskSuite: "mcp-realistic-noauth", tasks: [ @@ -1378,6 +1384,20 @@ describe("Pi live tool surface eval harness", () => { }); }); + it("builds repeated-workflow prompts without asking non-Code Mode modes to use Code Mode", async () => { + const suite = resolvePiEvalSuite("mcp-tool-use"); + const tasks = await loadTasks(suite.tasksPath); + const repeated = tasks.find((task: any) => task.id === "repeated-release-gates"); + expect(repeated).toBeTruthy(); + + const codeModePrompt = suite.buildPrompt(repeated, "caplets-code-mode"); + const directPrompt = suite.buildPrompt(repeated, "caplets-direct"); + + expect(codeModePrompt).toContain("adjacent Code Mode calls"); + expect(directPrompt).toContain("do not assume Code Mode is available"); + expect(directPrompt).not.toContain("adjacent Code Mode calls"); + }); + it("builds mode-specific prompts and Pi commands without user context files", () => { const task = { prompt: "Fix checkout retries." }; expect(buildPiEvalPrompt(task, "caplets-direct")).toContain("caplets____"); @@ -1888,10 +1908,12 @@ describe("Pi live tool surface eval harness", () => { it("loads MCP tool-use suite tasks with expected dependency metadata", async () => { const suite = resolvePiEvalSuite("mcp-tool-use"); const tasks = await loadTasks(suite.tasksPath); + const repeated = tasks.find((task: any) => task.id === "repeated-release-gates"); expect(tasks.map((task: any) => task.id)).toEqual([ "api-pagination-audit", "incident-customer-impact-join", "release-readiness-risk-report", + "repeated-release-gates", ]); expect(tasks[0]).toMatchObject({ dependency_analysis: { servers: ["api_catalog"] }, @@ -1899,6 +1921,18 @@ describe("Pi live tool surface eval harness", () => { }); expect(tasks[1].dependency_analysis.servers).toEqual(["incidents", "customers"]); expect(tasks[2].dependency_analysis.servers).toEqual(["deployments", "quality", "policies"]); + expect(repeated).toMatchObject({ + repeatedWorkflow: { + setupCodeMarkers: [ + "async function loadGateInputs", + "const summarizeGate =", + "const gateIds =", + ], + }, + expectedEvidence: { + tools: ["deployments.get_release", "quality.list_checks", "policies.get_release_policy"], + }, + }); }); it("loads realistic no-auth MCP workflow tasks with broad multi-server dependencies", async () => { @@ -2531,6 +2565,117 @@ describe("Pi live tool surface eval harness", () => { ); }); + it("summarizes repeated-workflow setup code from Code Mode tool calls", () => { + const metrics = summarizePiEvalMetrics([ + { + type: "tool_execution_start", + toolName: "caplets__code_mode", + input: { + code: [ + "async function loadGateInputs(id) { return await deployments.get_release({ id }); }", + "const summarizeGate = (gate) => gate.status;", + "const gateIds = ['deploy-2026-06-17', 'deploy-2026-06-18'];", + ].join("\n"), + sessionId: "session-1", + }, + }, + { + type: "tool_execution_start", + toolName: "caplets__code_mode", + input: { + code: "const decisions = await Promise.all(gateIds.map(loadGateInputs));", + sessionId: "session-1", + }, + }, + { + type: "tool_execution_start", + toolName: "deployments_get_release", + }, + ]); + + expect(metrics.repeatedWorkflow).toMatchObject({ + codeModeCallCount: 2, + sessionReuseCallCount: 1, + setupCodeCallCount: 1, + repeatedSetupCodeCallCount: 0, + setupCodeBytes: expect.any(Number), + setupCodeEstimatedTokens: expect.any(Number), + repeatedSetupCodeBytes: 0, + repeatedSetupCodeEstimatedTokens: 0, + setupCodeMarkers: ["async function loadGateInputs", "const summarizeGate", "const gateIds"], + }); + expect(metrics.repeatedWorkflow.setupCodeBytes).toBeGreaterThan(100); + expect(metrics.repeatedWorkflow.setupCodeReuseRate).toBe(1); + }); + + it("counts the first supplied session id after an initial fresh Code Mode call as reuse", () => { + const metrics = summarizePiEvalMetrics([ + { + type: "tool_execution_start", + toolName: "caplets__code_mode", + input: { + code: "function loadGateInputs(id) { return id; }\nconst gateIds = ['deploy'];", + }, + }, + { + type: "tool_execution_start", + toolName: "caplets__code_mode", + input: { + code: "return gateIds.map(loadGateInputs);", + sessionId: "session-from-previous-call", + }, + }, + ]); + + expect(metrics.repeatedWorkflow).toMatchObject({ + codeModeCallCount: 2, + sessionReuseCallCount: 1, + setupCodeReuseRate: 1, + }); + }); + + it("uses agent JSON tool inputs when Pi instrumentation only records tool names", () => { + const metrics = summarizePiEvalMetrics( + [ + { + type: "tool_execution_start", + toolName: "caplets__code_mode", + }, + { + type: "tool_execution_start", + toolName: "caplets__code_mode", + }, + ], + [ + { + type: "tool_execution_start", + toolName: "caplets__code_mode", + input: { + code: "function loadGateInputs(id) { return id; }\nconst gateIds = ['deploy'];", + sessionId: "session-json", + }, + }, + { + type: "tool_execution_start", + toolName: "caplets__code_mode", + input: { + code: "return gateIds.map(loadGateInputs);", + sessionId: "session-json", + }, + }, + ], + ); + + expect(metrics.toolCallEventSource).toBe("metrics-jsonl"); + expect(metrics.repeatedWorkflow).toMatchObject({ + codeModeCallCount: 2, + sessionReuseCallCount: 1, + setupCodeCallCount: 1, + setupCodeReuseRate: 1, + }); + expect(metrics.repeatedWorkflow.setupCodeEstimatedTokens).toBeGreaterThan(0); + }); + it("instruments OpenAI Responses input payloads into request token buckets", async () => { const root = await mkdtemp(join(tmpdir(), "caplets-pi-eval-instrumentation-test-")); const metricsPath = join(root, "metrics.jsonl"); @@ -3816,6 +3961,16 @@ describe("Pi live tool surface eval harness", () => { hybridChoice: "code-mode-only", domainCoverage: { issues: true, ci: true, docs: true, api: true, codeMap: false }, providerUsage: { totalTokens: 120 }, + repeatedWorkflow: { + codeModeCallCount: 2, + setupCodeCallCount: 1, + repeatedSetupCodeCallCount: 0, + setupCodeBytes: 120, + setupCodeEstimatedTokens: 30, + repeatedSetupCodeBytes: 0, + repeatedSetupCodeEstimatedTokens: 0, + setupCodeReuseRate: 0.5, + }, }, }; const executorResult = { @@ -3847,6 +4002,16 @@ describe("Pi live tool surface eval harness", () => { toolEventCount: 6, hybridChoice: "vanilla-mcp-only", providerUsage: { totalTokens: 240, outputTokens: 20 }, + repeatedWorkflow: { + codeModeCallCount: 0, + setupCodeCallCount: 0, + repeatedSetupCodeCallCount: 0, + setupCodeBytes: 260, + setupCodeEstimatedTokens: 65, + repeatedSetupCodeBytes: 260, + repeatedSetupCodeEstimatedTokens: 65, + setupCodeReuseRate: 0, + }, }, }; const summary = summarizePiEvalResults([result, executorResult, vanillaResult]); @@ -3874,6 +4039,8 @@ describe("Pi live tool surface eval harness", () => { averageProviderTokens: 120, }), averageToolCalls: 1, + averageRepeatedSetupCodeEstimatedTokens: 0, + averageSetupCodeReuseRate: 0.5, averageRequestTokenBuckets: expect.objectContaining({ requestPayloadEstimatedTokens: 100, toolSurfaceEstimatedTokens: 60, @@ -3889,6 +4056,7 @@ describe("Pi live tool surface eval harness", () => { averageRequestPlusOutputEstimatedTokens: 212, passedOnly: null, averageToolCalls: 2, + averageRepeatedSetupCodeEstimatedTokens: 0, hybridChoice: { "executor-only": 1 }, }), ]), @@ -3920,6 +4088,7 @@ describe("Pi live tool surface eval harness", () => { expect.arrayContaining([ expect.objectContaining({ label: "caplets-code-mode vs vanilla-mcp", + repeatedSetupTokenReduction: expect.any(Number), requestPlusOutputTokenReduction: expect.any(Number), passRateComparable: true, publishableTokenEfficiencyClaim: true, @@ -3944,6 +4113,8 @@ describe("Pi live tool surface eval harness", () => { expect(markdown).toContain("request+output tokens"); expect(markdown).toContain("Avg LLM round trips"); expect(markdown).toContain("Avg provider tokens"); + expect(markdown).toContain("Avg repeated setup-code tokens"); + expect(markdown).toContain("setup-code tokens"); expect(markdown).toContain("Avg non-surface estimated tokens"); expect(markdown).toContain("provider tokens"); expect(markdown).toContain("## Validator Summary"); diff --git a/packages/benchmarks/test/code-mode-complex-workflow.test.ts b/packages/benchmarks/test/code-mode-complex-workflow.test.ts index ab11861e..a23be683 100644 --- a/packages/benchmarks/test/code-mode-complex-workflow.test.ts +++ b/packages/benchmarks/test/code-mode-complex-workflow.test.ts @@ -3,8 +3,10 @@ import { CODE_MODE_COMPLEX_WORKFLOW_THRESHOLDS, computeCodeModeComplexWorkflowEval, computeCodeModeLiveRegressionEval, + computeCodeModeRepeatedWorkflowEval, validateCodeModeComplexWorkflowEval, validateCodeModeLiveRegressionEval, + validateCodeModeRepeatedWorkflowEval, } from "../lib/code-mode"; describe("Code Mode complex workflow eval", () => { @@ -43,4 +45,51 @@ describe("Code Mode complex workflow eval", () => { expect(result.improvements).toContain("schema-error-call-signatures"); expect(result.improvements).toContain("transport-body-normalization"); }); + + it("captures repeated-workflow session reuse without deterministic live claims", () => { + const result = computeCodeModeRepeatedWorkflowEval(); + const codeMode = result.strategies.find((strategy) => strategy.strategy === "code-mode")!; + const progressive = result.strategies.find( + (strategy) => strategy.strategy === "progressive-disclosure", + )!; + + expect(validateCodeModeRepeatedWorkflowEval(result)).toEqual([]); + expect(result.task.id).toBe("repeated-release-gates"); + expect(codeMode.taskSuccess).toBe(true); + expect(progressive.taskSuccess).toBe(true); + expect(codeMode.setupCodeEstimatedTokens).toBeLessThan(progressive.setupCodeEstimatedTokens); + expect(codeMode.providerRequests).toBeLessThan(progressive.providerRequests); + expect(codeMode.setupCodeReuseRate).toBeGreaterThan(0); + expect(result.reductions.setupCodeTokens).toBeGreaterThanOrEqual( + CODE_MODE_COMPLEX_WORKFLOW_THRESHOLDS.minRepeatedSetupTokenReduction, + ); + expect(result.reductions.providerRequests).toBeGreaterThanOrEqual( + CODE_MODE_COMPLEX_WORKFLOW_THRESHOLDS.minRepeatedProviderRequestReduction, + ); + expect(result.reductions.toolCalls).toBeGreaterThanOrEqual( + CODE_MODE_COMPLEX_WORKFLOW_THRESHOLDS.minRepeatedToolCallReduction, + ); + expect(result.reductions.requestOverheadTokenProxy).toBeGreaterThanOrEqual( + CODE_MODE_COMPLEX_WORKFLOW_THRESHOLDS.minRepeatedRequestOverheadReduction, + ); + expect(result.reductions.elapsedMs).toBeGreaterThanOrEqual( + CODE_MODE_COMPLEX_WORKFLOW_THRESHOLDS.minRepeatedElapsedTimeReduction, + ); + expect(result.claim).toContain("deterministic metric shape"); + }); + + it("fails repeated-workflow validation when claimed dimensions regress", () => { + const result = computeCodeModeRepeatedWorkflowEval(); + result.reductions.toolCalls = 0; + result.reductions.requestOverheadTokenProxy = 0; + result.reductions.elapsedMs = 0; + + expect(validateCodeModeRepeatedWorkflowEval(result)).toEqual( + expect.arrayContaining([ + "Repeated workflow tool-call reduction is below threshold.", + "Repeated workflow request-overhead proxy reduction is below threshold.", + "Repeated workflow elapsed-time reduction is below threshold.", + ]), + ); + }); }); diff --git a/packages/core/src/attach/server.ts b/packages/core/src/attach/server.ts index 56b363cb..fe5ba191 100644 --- a/packages/core/src/attach/server.ts +++ b/packages/core/src/attach/server.ts @@ -39,11 +39,8 @@ function createAttachNativeService(options: AttachServeOptions, io: AttachServeI mode: options.selection.kind === "hosted_cloud" ? "cloud" : "remote", configPath: options.configPath, projectConfigPath: options.projectConfigPath, - server: { - url: options.selection.remote.baseUrl.toString(), - ...(options.selection.remote.fetch ? { fetch: options.selection.remote.fetch } : {}), - }, remote: { + url: options.selection.remote.baseUrl.toString(), ...(options.selection.remote.fetch ? { fetch: options.selection.remote.fetch } : {}), ...(options.selection.kind === "hosted_cloud" ? { diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index e2e287c9..c8c43c3d 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -23,7 +23,7 @@ import { type AuthStatusRow, } from "./cli/auth"; import { cliCommands } from "./cli/commands"; -import { codeModeTypesCli, runCodeModeCli } from "./cli/code-mode"; +import { codeModeTypesCli, runCodeModeCli, runCodeModeReplCli } from "./cli/code-mode"; import { initConfig } from "./cli/init"; import { doctorJsonReport, formatDoctorReport } from "./cli/doctor"; import { @@ -76,7 +76,7 @@ import { ProjectBindingError } from "./project-binding/errors"; import type { ProjectBindingWebSocketFactory } from "./project-binding/transport"; import { RemoteControlClient } from "./remote-control/client"; import type { RemoteCliCommand } from "./remote-control/types"; -import { resolveCapletsMode, resolveCapletsServer } from "./server/options"; +import { resolveCapletsRemote, resolveRemoteMode } from "./remote/options"; import { daemonStatus, disableDaemon, @@ -389,13 +389,49 @@ export function createProgram(io: CliIO = {}): Command { .description("Run, inspect, and debug Caplets Code Mode.") .argument("[code]", "inline TypeScript code to run") .option("--file ", "read TypeScript code from a file relative to the current directory") + .option("--session-id ", "optional Code Mode session identifier") + .option("--recover ", "recover a prior Code Mode REPL session when supported") .option("--timeout-ms ", "execution timeout in milliseconds", parsePositiveInteger) .option("--json", "print the structured run envelope") .action( async ( code: string | undefined, - options: { file?: string; timeoutMs?: number; json?: boolean }, + options: { + file?: string; + sessionId?: string; + recover?: string; + timeoutMs?: number; + json?: boolean; + }, ) => { + if (code === "repl" && options.file === undefined) { + await runCodeModeReplCli({ + env, + ...(currentConfigPath() ? { configPath: currentConfigPath() } : {}), + projectConfigPath: envProjectConfigPath(env), + ...(io.authDir ? { authDir: io.authDir } : {}), + ...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }), + ...(options.recover === undefined ? {} : { recoveryRef: options.recover }), + ...(options.json === undefined ? {} : { json: options.json }), + writeOut, + setExitCode, + }); + return; + } + if (options.recover !== undefined) { + await runCodeModeReplCli({ + env, + ...(currentConfigPath() ? { configPath: currentConfigPath() } : {}), + projectConfigPath: envProjectConfigPath(env), + ...(io.authDir ? { authDir: io.authDir } : {}), + ...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }), + recoveryRef: options.recover, + ...(options.json === undefined ? {} : { json: options.json }), + writeOut, + setExitCode, + }); + return; + } await runCodeModeCli({ env, ...(currentConfigPath() ? { configPath: currentConfigPath() } : {}), @@ -403,6 +439,7 @@ export function createProgram(io: CliIO = {}): Command { ...(io.authDir ? { authDir: io.authDir } : {}), ...(code === undefined ? {} : { inlineCode: code }), ...(options.file === undefined ? {} : { file: options.file }), + ...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }), ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), ...(options.json === undefined ? {} : { json: options.json }), ...(io.readStdin ? { readStdin: io.readStdin } : {}), @@ -1867,10 +1904,10 @@ function envConfigPath( function remoteClientForCli(io: CliIO): RemoteControlClient | undefined { const env = io.env ?? process.env; - if (resolveCapletsMode({}, env).mode !== "remote") { + if (resolveRemoteMode({}, env).mode !== "remote") { return undefined; } - const server = resolveCapletsServer(io.fetch ? { fetch: io.fetch } : {}, env); + const server = resolveCapletsRemote(io.fetch ? { fetch: io.fetch } : {}, env); return new RemoteControlClient(server); } @@ -2086,7 +2123,7 @@ function requireRemoteClientForTarget(io: CliIO): RemoteControlClient { if (!remote) { throw new CapletsError( "REQUEST_INVALID", - "--remote requires CAPLETS_MODE=remote and CAPLETS_SERVER_URL", + "--remote requires CAPLETS_MODE=remote and CAPLETS_REMOTE_URL", ); } return remote; diff --git a/packages/core/src/cli/code-mode.ts b/packages/core/src/cli/code-mode.ts index ff905dfb..b9e6ddce 100644 --- a/packages/core/src/cli/code-mode.ts +++ b/packages/core/src/cli/code-mode.ts @@ -1,8 +1,9 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { createNativeCapletsService } from "../native/service"; -import { nativeCodeModeToolId } from "../native/tools"; import { codeModeDeclarationHash, generateCodeModeDeclarations } from "../code-mode/declarations"; +import { runCodeMode } from "../code-mode/runner"; +import { emptyCodeModeRunMeta } from "../code-mode/tool"; import type { CodeModeCallableCaplet, CodeModeRunEnvelope, @@ -19,6 +20,8 @@ export type CodeModeCliOptions = { inlineCode?: string | undefined; file?: string | undefined; timeoutMs?: number | undefined; + sessionId?: string | undefined; + recoveryRef?: string | undefined; json?: boolean | undefined; readStdin?: (() => Promise) | undefined; writeOut: (value: string) => void; @@ -33,11 +36,33 @@ export async function runCodeModeCli(options: CodeModeCliOptions): Promise ...(options.authDir ? { authDir: options.authDir } : {}), }); try { + if (options.sessionId !== undefined) { + const result: CodeModeRunEnvelope = { + ok: false, + error: { + code: "SESSION_NOT_FOUND", + message: + "Code Mode one-shot CLI runs do not support --session-id. Omit --session-id to start a fresh one-shot run.", + }, + diagnostics: [], + logs: { entries: [], truncated: false, stored: false }, + meta: emptyCodeModeRunMeta(), + }; + if (options.json) { + options.writeOut(`${JSON.stringify(result, null, 2)}\n`); + } else { + options.writeOut(`${result.error.code}: ${result.error.message}\n`); + } + options.setExitCode(1); + return; + } const code = await readCodeModeCliCode(options); - const result = (await service.execute(nativeCodeModeToolId, { + const result = await runCodeMode({ code, + service: service.codeModeService?.() ?? service, ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), - })) as CodeModeRunEnvelope; + runtimeScope: "cli-one-shot", + }); if (options.json) { options.writeOut(`${JSON.stringify(result, null, 2)}\n`); } else if (result.ok) { @@ -58,6 +83,39 @@ export async function runCodeModeCli(options: CodeModeCliOptions): Promise } } +export async function runCodeModeReplCli( + options: Pick< + CodeModeCliOptions, + | "env" + | "configPath" + | "projectConfigPath" + | "authDir" + | "sessionId" + | "recoveryRef" + | "json" + | "writeOut" + | "setExitCode" + >, +): Promise { + const envelope: CodeModeRunEnvelope = { + ok: false, + error: { + code: "UNSUPPORTED_OPERATION", + message: + "Code Mode REPL sessions are not available in this build. Use `caplets code-mode` for one-shot runs.", + }, + diagnostics: [], + logs: { entries: [], truncated: false, stored: false }, + meta: emptyCodeModeRunMeta(), + }; + if (options.json) { + options.writeOut(`${JSON.stringify(envelope, null, 2)}\n`); + } else { + options.writeOut(`${envelope.error.code}: ${envelope.error.message}\n`); + } + options.setExitCode(1); +} + export async function codeModeTypesCli( options: Pick< CodeModeCliOptions, diff --git a/packages/core/src/cli/setup.ts b/packages/core/src/cli/setup.ts index 200fb858..56de9976 100644 --- a/packages/core/src/cli/setup.ts +++ b/packages/core/src/cli/setup.ts @@ -314,7 +314,6 @@ function remoteSetupDefinition( nonEmpty(options.remoteUrl) ?? nonEmpty(options.serverUrl) ?? nonEmpty(options.env?.CAPLETS_REMOTE_URL) ?? - nonEmpty(options.env?.CAPLETS_SERVER_URL) ?? "https://caplets.example.com/caplets"; if (id === "opencode") { @@ -329,8 +328,8 @@ function remoteSetupDefinition( }, ], nextSteps: [ - `Run OpenCode with CAPLETS_MODE=remote and CAPLETS_SERVER_URL=${serverUrl}.`, - "Keep CAPLETS_SERVER_PASSWORD in your shell or secret manager.", + `Run OpenCode with CAPLETS_MODE=remote and CAPLETS_REMOTE_URL=${serverUrl}.`, + "Keep CAPLETS_REMOTE_TOKEN or CAPLETS_REMOTE_PASSWORD in your shell or secret manager.", ], }; } @@ -347,8 +346,8 @@ function remoteSetupDefinition( }, ], nextSteps: [ - `Start Pi with CAPLETS_MODE=remote and CAPLETS_SERVER_URL=${serverUrl}.`, - "Keep CAPLETS_SERVER_PASSWORD in your shell or secret manager.", + `Start Pi with CAPLETS_MODE=remote and CAPLETS_REMOTE_URL=${serverUrl}.`, + "Keep CAPLETS_REMOTE_TOKEN or CAPLETS_REMOTE_PASSWORD in your shell or secret manager.", ], }; } @@ -412,7 +411,7 @@ function remoteSetupDefinition( ], nextSteps: [ "Add Basic Auth credentials through your agent's secret mechanism.", - "Do not hardcode CAPLETS_SERVER_PASSWORD in a committed config file.", + "Do not hardcode CAPLETS_REMOTE_TOKEN or CAPLETS_REMOTE_PASSWORD in a committed config file.", ], }; } diff --git a/packages/core/src/code-mode/api.ts b/packages/core/src/code-mode/api.ts index 222ae67d..cd422f75 100644 --- a/packages/core/src/code-mode/api.ts +++ b/packages/core/src/code-mode/api.ts @@ -4,6 +4,8 @@ import type { CodeModeCallableCaplet, Page, PageInput, + ReadCodeModeRecoveryInput, + ReadCodeModeRecoveryResult, ReadLogsInput, ReadLogsResult, ToolCallError, @@ -34,6 +36,7 @@ export type CodeModeCapletHandle = { export type CodeModeDebugApi = { readLogs(input: ReadLogsInput): Promise; + readRecovery(input: ReadCodeModeRecoveryInput): Promise; }; export type CodeModeCapletsApi = { @@ -47,6 +50,7 @@ export type CodeModeCapletsApi = { export type CreateCodeModeCapletsApiInput = { service: NativeCapletsService; readLogs?: (input: ReadLogsInput) => Promise; + readRecovery?: (input: ReadCodeModeRecoveryInput) => Promise; }; export function listCodeModeCallableCaplets( @@ -88,6 +92,7 @@ export function createCodeModeCapletsApi(input: CreateCodeModeCapletsApiInput): const debugApi: CodeModeDebugApi = { readLogs: input.readLogs ?? defaultReadLogs, + readRecovery: input.readRecovery ?? defaultReadRecovery, }; api.debug = "debug" in api ? Object.assign(api.debug as CodeModeCapletHandle, debugApi) : debugApi; @@ -467,6 +472,10 @@ async function defaultReadLogs(): Promise { }; } +async function defaultReadRecovery(): Promise { + return { entries: [] }; +} + function resultIsError(result: unknown): boolean { if (!isPlainObject(result)) return false; if (result.isError === true) return true; diff --git a/packages/core/src/code-mode/declarations.ts b/packages/core/src/code-mode/declarations.ts index e4bfca4a..68e4b700 100644 --- a/packages/core/src/code-mode/declarations.ts +++ b/packages/core/src/code-mode/declarations.ts @@ -3,6 +3,8 @@ import { CODE_MODE_RUNTIME_API_DECLARATION } from "./runtime-api.generated"; const JS_IDENTIFIER = /^[A-Za-z_$][\w$]*$/u; const MAX_JSDOC_CHARS = 180; +const CODE_MODE_REPL_GUIDANCE = + "REPL reuse: omit `sessionId` to start a fresh reusable Code Mode session; after a successful run, keep `meta.sessionId` and pass it as `sessionId` on later calls when you want to reuse live state. Reused sessions preserve successful top-level `var` bindings, function declarations, and runtime state only while the live session remains available and compatible. A supplied `sessionId` that is unknown or no longer available fails before executing your code instead of starting an empty context. Use `meta.recoveryRef` with `caplets.debug.readRecovery({ recoveryRef })` for audit and manual reconstruction; do not automatically replay recovery history."; export function generateCodeModeDeclarations(input: CodeModeDeclarationInput): string { const caplets = [...input.caplets].sort((left, right) => left.id.localeCompare(right.id)); @@ -26,6 +28,7 @@ export function generateCodeModeDeclarations(input: CodeModeDeclarationInput): s export function generateCodeModeRunToolDescription(declaration: string): string { return [ 'Run TypeScript with generated `caplets.` handles and declaration hints below. Prefer a compact one-pass script for most tasks: discover, filter, execute, and synthesize inside Code Mode, then return only decision-ready JSON. Do not return full tool lists, full descriptors, schemas, raw tool payloads, or exploratory transcripts unless the user specifically needs them; keep bulky intermediate data inside the script. For discovery, use tools/searchTools for names and arg hints, then describeTool only for short-listed operations needing exact schemas, nested args, fields, or disambiguation. Never invent tool names, resource URIs, prompt names, input args, output fields, or schemas; use requiredArgs/acceptedArgs for simple calls, otherwise use describeTool for the exact callSignature/inputSchema/inputTypeScript. For fallback, check candidate handles first: `for(const h of candidates){const ready=await h.check();if(!ready.ok)continue;}`. For triage, list broad candidate records and filter in script before targeted searches so adjacent relevant items are not missed. Execute with exact args, handle `{ok:false}`, and derive final recommendations from all relevant records, not the first matching record. If records disagree or have ranges/statuses, compute the strictest applicable conclusion and preserve only the compact evidence used. Return summaries, key ids/names/titles/statuses/urls, derived fields, recommendation, caveats, and residual missing data. Before returning, remove unused descriptors/schemas/raw content. Pattern: `const h=caplets["caplet-id"];const tools=await h.searchTools("query");const d=needSchema?await h.describeTool("tool_name"):undefined;const r=await h.callTool("tool_name",args);return {facts:[...],evidence:[...]};`', + CODE_MODE_REPL_GUIDANCE, "", "Generated declaration hints:", "```ts", diff --git a/packages/core/src/code-mode/diagnostics.ts b/packages/core/src/code-mode/diagnostics.ts index b228f014..0125f0ae 100644 --- a/packages/core/src/code-mode/diagnostics.ts +++ b/packages/core/src/code-mode/diagnostics.ts @@ -8,6 +8,7 @@ export type DiagnoseCodeModeTypeScriptInput = { declaration: string; maxDiagnostics?: number; timeoutMs?: number; + session?: CodeModeDiagnosticsSession; }; const CODE_FILE = "/caplets-code-mode/input.ts"; @@ -29,18 +30,7 @@ export function diagnoseCodeModeTypeScript( return diagnostics.slice(0, maxDiagnostics); } - const compilerOptions: ts.CompilerOptions = { - target: ts.ScriptTarget.ES2022, - module: ts.ModuleKind.ESNext, - moduleResolution: ts.ModuleResolutionKind.Bundler, - lib: ["lib.es2022.d.ts"], - types: [], - strict: true, - noEmit: true, - skipLibCheck: true, - noErrorTruncation: true, - allowJs: false, - }; + const compilerOptions = codeModeCompilerOptions(); const wrappedCode = [ "async function __capletsCodeModeMain(): Promise {", input.code, @@ -49,7 +39,10 @@ export function diagnoseCodeModeTypeScript( const host = createVirtualCompilerHost(compilerOptions, { [CODE_FILE]: wrappedCode, [DECLARATION_FILE]: input.declaration, - [AMBIENT_FILE]: CODE_MODE_DIAGNOSTICS_BUILTINS_DECLARATION, + [AMBIENT_FILE]: [ + CODE_MODE_DIAGNOSTICS_BUILTINS_DECLARATION, + input.session?.declaration() ?? "", + ].join("\n"), }); const program = ts.createProgram( [CODE_FILE, DECLARATION_FILE, AMBIENT_FILE], @@ -86,6 +79,332 @@ export function diagnoseCodeModeTypeScript( return diagnostics.slice(0, maxDiagnostics); } +function codeModeCompilerOptions(): ts.CompilerOptions { + return { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + lib: ["lib.es2022.d.ts"], + types: [], + strict: true, + noEmit: true, + skipLibCheck: true, + noErrorTruncation: true, + allowJs: false, + }; +} + +export class CodeModeDiagnosticsSession { + #declarations = new Map(); + + declaration(): string { + return [...this.#declarations.values()].join("\n"); + } + + recordSuccessfulCell(code: string, declaration = ""): void { + const source = ts.createSourceFile( + "/caplets-code-mode/session-cell.ts", + code, + ts.ScriptTarget.ES2022, + true, + ); + const compilerOptions = codeModeCompilerOptions(); + const ambientDeclarations = [ + CODE_MODE_DIAGNOSTICS_BUILTINS_DECLARATION, + declaration, + this.declaration(), + ].join("\n"); + const host = createVirtualCompilerHost(compilerOptions, { + [CODE_FILE]: code, + [AMBIENT_FILE]: ambientDeclarations, + }); + const program = ts.createProgram([CODE_FILE, AMBIENT_FILE], compilerOptions, host); + const checker = program.getTypeChecker(); + const programSource = program.getSourceFile(CODE_FILE) ?? source; + for (const statement of programSource.statements) { + if (ts.isFunctionDeclaration(statement) && statement.name) { + const typeParameters = statement.typeParameters + ? `<${statement.typeParameters.map((typeParameter) => typeParameter.getText(programSource)).join(", ")}>` + : ""; + const params = statement.parameters.map((parameter) => + ambientParameter(parameter, programSource), + ); + const signature = checker.getSignatureFromDeclaration(statement); + const inferredReturnType = signature + ? safeAmbientFunctionReturnType( + checker.getReturnTypeOfSignature(signature), + checker, + ambientDeclarations, + ) + : "unknown"; + const returnType = statement.type + ? statement.type.getText(programSource) + : inferredReturnType; + this.#declarations.set( + statement.name.text, + `declare function ${statement.name.text}${typeParameters}(${params.join(", ")}): ${returnType};`, + ); + } + } + const previousBindingNames = new Set(this.#declarations.keys()); + const assignedNames = collectFunctionScopedAssignedNames(programSource); + for (const binding of collectFunctionScopedVarBindings(programSource, checker, { + ambientDeclarationFor: (name) => + [CODE_MODE_DIAGNOSTICS_BUILTINS_DECLARATION, declaration, this.declarationExcluding(name)] + .filter(Boolean) + .join("\n"), + assignedNames, + previousBindingNames, + })) { + this.#declarations.set(binding.name, `declare var ${binding.name}: ${binding.type};`); + } + } + + clear(): void { + this.#declarations.clear(); + } + + private declarationExcluding(name: string): string { + const declarations: string[] = [CODE_MODE_DIAGNOSTICS_BUILTINS_DECLARATION]; + for (const [declarationName, declaration] of this.#declarations) { + if (declarationName !== name) { + declarations.push(declaration); + } + } + return declarations.join("\n"); + } +} + +type AmbientVarBinding = { + name: string; + type: string; +}; + +type AmbientVarBindingOptions = { + ambientDeclarationFor: (name: string) => string; + assignedNames: ReadonlySet; + previousBindingNames: ReadonlySet; +}; + +function collectFunctionScopedVarBindings( + source: ts.SourceFile, + checker: ts.TypeChecker, + options: AmbientVarBindingOptions, +): AmbientVarBinding[] { + const bindings = new Map(); + const visit = (node: ts.Node): void => { + if (node !== source && (ts.isFunctionLike(node) || ts.isClassLike(node))) { + return; + } + if (ts.isVariableStatement(node)) { + collectVarDeclarationListBindings(node.declarationList, checker, bindings, options); + } + if ( + (ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node)) && + node.initializer && + ts.isVariableDeclarationList(node.initializer) + ) { + collectVarDeclarationListBindings(node.initializer, checker, bindings, options); + } + ts.forEachChild(node, visit); + }; + visit(source); + return [...bindings.entries()].map(([name, type]) => ({ name, type })); +} + +function collectVarDeclarationListBindings( + declarationList: ts.VariableDeclarationList, + checker: ts.TypeChecker, + bindings: Map, + options: AmbientVarBindingOptions, +): void { + const isVar = (ts.getCombinedNodeFlags(declarationList) & ts.NodeFlags.BlockScoped) === 0; + if (!isVar) { + return; + } + for (const declaration of declarationList.declarations) { + for (const name of bindingNames(declaration.name)) { + const type = ambientTypeForBindingName( + name, + checker, + options.ambientDeclarationFor(name.text), + ); + if ( + options.previousBindingNames.has(name.text) && + declaration.initializer === undefined && + !options.assignedNames.has(name.text) + ) { + continue; + } + bindings.set(name.text, type); + } + } +} + +function bindingNames(name: ts.BindingName): ts.Identifier[] { + if (ts.isIdentifier(name)) { + return [name]; + } + return name.elements.flatMap((element) => { + if (ts.isOmittedExpression(element)) { + return []; + } + return bindingNames(element.name); + }); +} + +function collectFunctionScopedAssignedNames(source: ts.SourceFile): Set { + const names = new Set(); + const visit = (node: ts.Node): void => { + if (node !== source && (ts.isFunctionLike(node) || ts.isClassLike(node))) { + return; + } + if (ts.isBinaryExpression(node) && isAssignmentOperator(node.operatorToken.kind)) { + collectAssignedBindingNames(node.left, names); + } + if ( + (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && + (node.operator === ts.SyntaxKind.PlusPlusToken || + node.operator === ts.SyntaxKind.MinusMinusToken) + ) { + collectAssignedBindingNames(node.operand, names); + } + ts.forEachChild(node, visit); + }; + visit(source); + return names; +} + +function isAssignmentOperator(kind: ts.SyntaxKind): boolean { + return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment; +} + +function collectAssignedBindingNames(node: ts.Node, names: Set): void { + if (ts.isIdentifier(node)) { + names.add(node.text); + return; + } + if (ts.isPropertyAssignment(node)) { + collectAssignedBindingNames(node.initializer, names); + return; + } + if (ts.isShorthandPropertyAssignment(node)) { + names.add(node.name.text); + return; + } + if (ts.isSpreadAssignment(node)) { + collectAssignedBindingNames(node.expression, names); + return; + } + if (ts.isObjectLiteralExpression(node) || ts.isArrayLiteralExpression(node)) { + ts.forEachChild(node, (child) => collectAssignedBindingNames(child, names)); + } +} + +function ambientTypeForBindingName( + name: ts.Identifier, + checker: ts.TypeChecker, + ambientDeclaration: string, +): string { + const type = checker.getTypeAtLocation(name); + return safeAmbientType(name.text, type, checker, ambientDeclaration); +} + +function safeAmbientType( + name: string, + type: ts.Type, + checker: ts.TypeChecker, + ambientDeclaration: string, +): string { + return safeAmbientTypeText(type, checker, ambientDeclaration, (typeText) => + isSelfContainedAmbientDeclaration(`declare let ${name}: ${typeText};`, ambientDeclaration), + ); +} + +function safeAmbientFunctionReturnType( + type: ts.Type, + checker: ts.TypeChecker, + ambientDeclaration: string, +): string { + return safeAmbientTypeText(type, checker, ambientDeclaration, (typeText) => + isSelfContainedAmbientDeclaration( + `declare function __caplets_return_probe__(): ${typeText};`, + ambientDeclaration, + ), + ); +} + +function safeAmbientTypeText( + type: ts.Type, + checker: ts.TypeChecker, + ambientDeclaration: string, + isSelfContained: (typeText: string) => boolean, +): string { + if (isUnsafeAmbientType(type)) { + return "unknown"; + } + const text = checker.typeToString( + type, + undefined, + ts.TypeFormatFlags.NoTruncation | + ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope | + ts.TypeFormatFlags.WriteArrayAsGenericType, + ); + if (!text || text === "any" || text === "{}" || text === "never") { + return "unknown"; + } + if (text.length > 500) { + return "unknown"; + } + if (/\bimport\(/u.test(text)) { + return "unknown"; + } + if (!isSelfContained(text)) { + return "unknown"; + } + return text; +} + +function isUnsafeAmbientType(type: ts.Type): boolean { + return Boolean(type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)); +} + +function isSelfContainedAmbientDeclaration( + candidateDeclaration: string, + ambientDeclaration: string, +): boolean { + const compilerOptions = codeModeCompilerOptions(); + const host = createVirtualCompilerHost(compilerOptions, { + [CODE_FILE]: candidateDeclaration, + [AMBIENT_FILE]: ambientDeclaration, + }); + const program = ts.createProgram([CODE_FILE, AMBIENT_FILE], compilerOptions, host); + return program.getSemanticDiagnostics(program.getSourceFile(CODE_FILE)).length === 0; +} + +function ambientParameter(parameter: ts.ParameterDeclaration, source: ts.SourceFile): string { + const dotDotDot = parameter.dotDotDotToken ? "..." : ""; + const name = parameter.name.getText(source); + const optional = parameter.questionToken || parameter.initializer ? "?" : ""; + const type = + parameter.type?.getText(source) ?? + (parameter.initializer ? inferLiteralType(parameter.initializer) : "unknown"); + return `${dotDotDot}${name}${optional}: ${type}`; +} + +function inferLiteralType(initializer: ts.Expression | undefined): string { + if (!initializer) return "unknown"; + if (ts.isNumericLiteral(initializer)) return "number"; + if (ts.isStringLiteral(initializer)) return "string"; + if ( + initializer.kind === ts.SyntaxKind.TrueKeyword || + initializer.kind === ts.SyntaxKind.FalseKeyword + ) { + return "boolean"; + } + return "unknown"; +} + function preflightDiagnostics(code: string): CodeModeDiagnostic[] { const diagnostics: CodeModeDiagnostic[] = []; if (!hasExecutableImport(code)) { diff --git a/packages/core/src/code-mode/index.ts b/packages/core/src/code-mode/index.ts index f68c7fd9..5c960f4e 100644 --- a/packages/core/src/code-mode/index.ts +++ b/packages/core/src/code-mode/index.ts @@ -11,12 +11,17 @@ export type { CodeModeDiagnostic, CodeModeLogEntry, CodeModeLogs, + CodeModeRecoveryClassification, + CodeModeRecoveryEntry, CodeModeRunEnvelope, CodeModeRunError, CodeModeRunMeta, + CodeModeSessionStatus, CodeModeTypesJson, JsonPrimitive, JsonValue, + ReadCodeModeRecoveryInput, + ReadCodeModeRecoveryResult, ReadLogsInput, ReadLogsResult, ToolCallError, diff --git a/packages/core/src/code-mode/journal.ts b/packages/core/src/code-mode/journal.ts new file mode 100644 index 00000000..3ac96121 --- /dev/null +++ b/packages/core/src/code-mode/journal.ts @@ -0,0 +1,563 @@ +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { createHmac, randomBytes } from "node:crypto"; +import { dirname, join, relative, resolve } from "node:path"; +import { defaultStateBaseDir } from "../config/paths"; +import type { CodeModeDiagnostic } from "./types"; +import { redactCodeModeLogText } from "./logs"; + +const DEFAULT_JOURNAL_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000; +const DEFAULT_MAX_ENTRIES = 100; +const DEFAULT_MAX_CODE_BYTES = 64 * 1024; +const DEFAULT_MAX_SUMMARY_BYTES = 16 * 1024; +const DEFAULT_PAGE_LIMIT = 25; +const MAX_PAGE_LIMIT = 100; +const JOURNAL_VERSION = 1; + +export type CodeModeRecoveryClassification = "setup_like" | "side_effecting" | "unknown"; + +export type CodeModeJournalOutcome = { ok: true } | { ok: false; code: string; message: string }; + +export type CodeModeJournalEntry = { + timestamp: string; + code: string; + declarationHash: string; + outcome: CodeModeJournalOutcome; + diagnostics: Array>; + recoveryClassification: CodeModeRecoveryClassification; + logsStored?: boolean; + summary?: string; +}; + +export type StoreCodeModeJournalEntryInput = { + sessionId: string; + journalScope?: string; + code: string; + declarationHash: string; + outcome: CodeModeJournalOutcome; + diagnostics: CodeModeDiagnostic[]; + recoveryClassification: CodeModeRecoveryClassification; + logRef?: string; + summary?: string; +}; + +export type StoreCodeModeJournalEntryResult = { + recoveryRef: string; + expiresAt: string; + journalKey: string; +}; + +export type ReadCodeModeRecoveryInput = { + recoveryRef: string; + cursor?: string; + limit?: number; +}; + +export type ReadCodeModeRecoveryResult = { + entries: CodeModeJournalEntry[]; + nextCursor?: string; +}; + +export type CodeModeJournalLookupResult = { + expiresAt: string; + recoveryRef?: string; +}; + +export type CodeModeJournalStoreOptions = { + stateDir?: string; + now?: () => Date; + retentionMs?: number; + maxEntries?: number; + maxCodeBytes?: number; + maxSummaryBytes?: number; + secret?: string; +}; + +type StoredJournalFile = { + version: typeof JOURNAL_VERSION; + journalKey: string; + sessionIdHash?: string; + recoveryRefHashes: string[]; + createdAt: string; + updatedAt: string; + expiresAt: string; + entries: CodeModeJournalEntry[]; +}; + +type StoredJournalIndex = { + version: typeof JOURNAL_VERSION; + journalKey: string; + updatedAt: string; +}; + +export class CodeModeJournalStore { + private readonly stateDir: string; + private readonly now: () => Date; + private readonly retentionMs: number; + private readonly maxEntries: number; + private readonly maxCodeBytes: number; + private readonly maxSummaryBytes: number; + private readonly configuredSecret: string | undefined; + private secret: string | undefined; + private recoveryRefs = new Map(); + private sessionRecoveryRefs = new Map(); + private nextPruneAt = 0; + + constructor(options: CodeModeJournalStoreOptions = {}) { + this.stateDir = options.stateDir ?? join(defaultStateBaseDir(), "caplets"); + this.now = options.now ?? (() => new Date()); + this.retentionMs = options.retentionMs ?? DEFAULT_JOURNAL_RETENTION_MS; + this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; + this.maxCodeBytes = options.maxCodeBytes ?? DEFAULT_MAX_CODE_BYTES; + this.maxSummaryBytes = options.maxSummaryBytes ?? DEFAULT_MAX_SUMMARY_BYTES; + this.configuredSecret = options.secret; + } + + async store(input: StoreCodeModeJournalEntryInput): Promise { + this.ensureJournalDir(); + this.pruneExpired(); + const now = this.now(); + const expiresAt = new Date(now.getTime() + this.retentionMs).toISOString(); + const journalKey = this.journalKey(input.sessionId, input.journalScope); + const recoveryRef = + this.recoveryRefs.get(journalKey) ?? this.recoveryRefForJournalKey(journalKey); + this.recoveryRefs.set(journalKey, recoveryRef); + this.sessionRecoveryRefs.set(input.sessionId, { recoveryRef, expiresAt }); + const recoveryRefHash = this.recoveryRefHash(recoveryRef); + const path = this.journalPath(journalKey); + const existing = this.readJournalPath(path); + const entry = this.entryFromInput(input, now, recoveryRef); + const stored: StoredJournalFile = { + version: JOURNAL_VERSION, + journalKey, + sessionIdHash: this.sessionIdHash(input.sessionId), + recoveryRefHashes: [...new Set([...(existing?.recoveryRefHashes ?? []), recoveryRefHash])], + createdAt: existing?.createdAt ?? now.toISOString(), + updatedAt: now.toISOString(), + expiresAt, + entries: [...(existing?.entries ?? []), entry].slice(-this.maxEntries), + }; + this.writeJournal(path, stored); + this.writeIndex(this.recoveryIndexPath(recoveryRefHash), stored); + this.writeIndex(this.sessionIndexPath(stored.sessionIdHash), stored); + return { recoveryRef, expiresAt, journalKey }; + } + + async lookupSession(sessionId: string): Promise { + this.ensureJournalDir(); + this.pruneExpired(); + const retained = this.sessionRecoveryRefs.get(sessionId); + if (retained && new Date(retained.expiresAt).getTime() > this.now().getTime()) { + return retained; + } + const scopedJournal = this.findBySessionIdHash(this.sessionIdHash(sessionId)); + if (scopedJournal && !this.isExpired(scopedJournal)) { + return { + expiresAt: scopedJournal.expiresAt, + recoveryRef: this.recoveryRefForJournalKey(scopedJournal.journalKey), + }; + } + const journal = this.readJournalPath(this.journalPath(this.journalKey(sessionId))); + if (!journal || this.isExpired(journal)) return undefined; + return { + expiresAt: journal.expiresAt, + }; + } + + async lookupRecoveryRef(recoveryRef: string): Promise { + if (!isRecoveryRef(recoveryRef)) return undefined; + this.ensureJournalDir(); + this.pruneExpired(); + const journal = this.findByRecoveryRefHash(this.recoveryRefHash(recoveryRef)); + if (!journal || this.isExpired(journal)) return undefined; + return { expiresAt: journal.expiresAt }; + } + + async readRecovery(input: ReadCodeModeRecoveryInput): Promise { + if (!isRecoveryRef(input.recoveryRef)) { + return { entries: [] }; + } + this.ensureJournalDir(); + this.pruneExpired(); + const recoveryRefHash = this.recoveryRefHash(input.recoveryRef); + const journal = this.findByRecoveryRefHash(recoveryRefHash); + if (!journal || this.isExpired(journal)) return { entries: [] }; + const offset = parseCursor(input.cursor); + if (input.limit !== undefined && input.limit <= 0) { + return { entries: [] }; + } + const limit = Math.min(Math.max(input.limit ?? DEFAULT_PAGE_LIMIT, 0), MAX_PAGE_LIMIT); + const entries = journal.entries.slice(offset, offset + limit); + const nextOffset = offset + entries.length; + return nextOffset < journal.entries.length + ? { entries, nextCursor: String(nextOffset) } + : { entries }; + } + + private entryFromInput( + input: StoreCodeModeJournalEntryInput, + now: Date, + recoveryRef: string, + ): CodeModeJournalEntry { + const exactSecrets = [input.sessionId, recoveryRef, input.logRef].filter( + (value): value is string => Boolean(value), + ); + return { + timestamp: now.toISOString(), + code: truncateUtf8(redactJournalText(input.code, exactSecrets), this.maxCodeBytes), + declarationHash: input.declarationHash, + outcome: + input.outcome.ok === true + ? { ok: true } + : { + ok: false, + code: input.outcome.code, + message: truncateUtf8(redactJournalText(input.outcome.message, exactSecrets), 2_000), + }, + diagnostics: input.diagnostics.map((diagnostic) => ({ + code: diagnostic.code, + severity: diagnostic.severity, + message: truncateUtf8(redactJournalText(diagnostic.message, exactSecrets), 2_000), + })), + recoveryClassification: input.recoveryClassification, + ...(input.logRef ? { logsStored: true } : {}), + ...(input.summary + ? { + summary: truncateUtf8( + redactJournalText(input.summary, exactSecrets), + this.maxSummaryBytes, + ), + } + : {}), + }; + } + + private findByRecoveryRefHash(recoveryRefHash: string): StoredJournalFile | undefined { + const indexed = this.readIndexPath(this.recoveryIndexPath(recoveryRefHash)); + if (indexed) return this.readJournalPath(this.journalPath(indexed.journalKey)); + return this.findByRecoveryRefHashSlow(recoveryRefHash); + } + + private findBySessionIdHash(sessionIdHash: string): StoredJournalFile | undefined { + const indexed = this.readIndexPath(this.sessionIndexPath(sessionIdHash)); + if (indexed) return this.readJournalPath(this.journalPath(indexed.journalKey)); + return this.findBySessionIdHashSlow(sessionIdHash); + } + + private findByRecoveryRefHashSlow(recoveryRefHash: string): StoredJournalFile | undefined { + for (const filename of readdirSync(this.journalDir())) { + if (!filename.endsWith(".json")) continue; + const journal = this.readJournalPath(join(this.journalDir(), filename)); + if (journal?.recoveryRefHashes.includes(recoveryRefHash)) return journal; + } + return undefined; + } + + private findBySessionIdHashSlow(sessionIdHash: string): StoredJournalFile | undefined { + const journals: StoredJournalFile[] = []; + for (const filename of readdirSync(this.journalDir())) { + if (!filename.endsWith(".json")) continue; + const journal = this.readJournalPath(join(this.journalDir(), filename)); + if (journal?.sessionIdHash === sessionIdHash) journals.push(journal); + } + return journals.sort( + (left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(), + )[0]; + } + + private writeIndex(path: string, journal: StoredJournalFile): void { + const index: StoredJournalIndex = { + version: JOURNAL_VERSION, + journalKey: journal.journalKey, + updatedAt: journal.updatedAt, + }; + rejectSymlinkPathComponents(this.journalDir(), path, true); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + chmodSync(dirname(path), 0o700); + const tempPath = `${path}.${randomBytes(8).toString("hex")}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(index, null, 2)}\n`, { mode: 0o600 }); + chmodSync(tempPath, 0o600); + renameSync(tempPath, path); + chmodSync(path, 0o600); + } + + private readIndexPath(path: string): StoredJournalIndex | undefined { + try { + rejectSymlinkPathComponents(this.journalDir(), path, true); + if (!existsSync(path)) return undefined; + const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; + if ( + parsed.version !== JOURNAL_VERSION || + typeof parsed.journalKey !== "string" || + typeof parsed.updatedAt !== "string" + ) { + return undefined; + } + return { + version: JOURNAL_VERSION, + journalKey: parsed.journalKey, + updatedAt: parsed.updatedAt, + }; + } catch { + return undefined; + } + } + + private readJournalPath(path: string): StoredJournalFile | undefined { + try { + rejectSymlinkPathComponents(this.journalDir(), path, true); + if (!existsSync(path)) return undefined; + const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; + if ( + parsed.version !== JOURNAL_VERSION || + typeof parsed.journalKey !== "string" || + !Array.isArray(parsed.recoveryRefHashes) || + typeof parsed.createdAt !== "string" || + typeof parsed.updatedAt !== "string" || + typeof parsed.expiresAt !== "string" || + !Array.isArray(parsed.entries) + ) { + return undefined; + } + return { + version: JOURNAL_VERSION, + journalKey: parsed.journalKey, + ...(typeof parsed.sessionIdHash === "string" + ? { sessionIdHash: parsed.sessionIdHash } + : {}), + recoveryRefHashes: parsed.recoveryRefHashes.filter( + (hash): hash is string => typeof hash === "string", + ), + createdAt: parsed.createdAt, + updatedAt: parsed.updatedAt, + expiresAt: parsed.expiresAt, + entries: parsed.entries.filter(isJournalEntry), + }; + } catch { + return undefined; + } + } + + private writeJournal(path: string, journal: StoredJournalFile): void { + rejectSymlinkPathComponents(this.journalDir(), path, true); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + chmodSync(dirname(path), 0o700); + const tempPath = `${path}.${randomBytes(8).toString("hex")}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(journal, null, 2)}\n`, { mode: 0o600 }); + chmodSync(tempPath, 0o600); + renameSync(tempPath, path); + chmodSync(path, 0o600); + } + + private pruneExpired(): void { + const dir = this.journalDir(); + if (!existsSync(dir)) return; + const now = this.now().getTime(); + if (now < this.nextPruneAt) return; + this.nextPruneAt = now + 60_000; + for (const filename of readdirSync(dir)) { + if (!filename.endsWith(".json")) continue; + const path = join(dir, filename); + const journal = this.readJournalPath(path); + if (!journal || this.isExpired(journal)) { + rmSync(path, { force: true }); + } + } + } + + private isExpired(journal: StoredJournalFile): boolean { + return new Date(journal.expiresAt).getTime() <= this.now().getTime(); + } + + private journalKey(sessionId: string, journalScope = "default"): string { + return this.hmac(`journal:${sessionId}:${journalScope}`); + } + + private recoveryRefHash(recoveryRef: string): string { + return this.hmac(`recovery-ref:${recoveryRef}`); + } + + private recoveryRefForJournalKey(journalKey: string): string { + return this.hmac(`recovery-ref-material:${journalKey}`).slice(0, 48); + } + + private sessionIdHash(sessionId: string): string { + return this.hmac(`session-id:${sessionId}`); + } + + private hmac(value: string): string { + return createHmac("sha256", this.loadSecret()).update(value).digest("hex"); + } + + private loadSecret(): string { + if (this.configuredSecret) return this.configuredSecret; + if (this.secret) return this.secret; + this.ensureJournalDir(); + const path = this.secretPath(); + rejectSymlinkPathComponents(this.journalDir(), path, true); + if (existsSync(path)) { + this.secret = readFileSync(path, "utf8").trim(); + return this.secret; + } + this.secret = randomBytes(32).toString("hex"); + writeFileSync(path, `${this.secret}\n`, { mode: 0o600 }); + chmodSync(path, 0o600); + return this.secret; + } + + private ensureJournalDir(): void { + const dir = this.journalDir(); + rejectSymlinkRoot(resolve(this.stateDir)); + rejectSymlinkPathComponents(resolve(this.stateDir), dir, true); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + rejectSymlinkPathComponents(resolve(this.stateDir), dir, true); + chmodSync(dir, 0o700); + } + + private journalDir(): string { + return join(this.stateDir, "code-mode", "journal"); + } + + private journalPath(journalKey: string): string { + return join(this.journalDir(), `${journalKey}.json`); + } + + private recoveryIndexPath(recoveryRefHash: string): string { + return join(this.journalDir(), "recovery-index", `${recoveryRefHash}.json`); + } + + private sessionIndexPath(sessionIdHash: string | undefined): string { + return join(this.journalDir(), "session-index", `${sessionIdHash ?? "unknown"}.json`); + } + + private secretPath(): string { + return join(this.journalDir(), "secret.key"); + } +} + +export function classifyCodeModeRecovery(input: { + code: string; + invokedCaplet: boolean; + sessionDisposedAfterRun?: boolean; +}): CodeModeRecoveryClassification { + if (input.invokedCaplet) return "side_effecting"; + if (input.sessionDisposedAfterRun) return "unknown"; + return /\b(?:function|var|let|const|class)\b/u.test(input.code) ? "setup_like" : "unknown"; +} + +function isRecoveryRef(value: string): boolean { + return /^[a-f0-9]{48}$/u.test(value); +} + +function parseCursor(cursor: string | undefined): number { + if (!cursor) return 0; + const parsed = Number.parseInt(cursor, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} + +function truncateUtf8(value: string, maxBytes: number): string { + let bytes = 0; + let output = ""; + for (const char of value) { + const next = Buffer.byteLength(char, "utf8"); + if (bytes + next > maxBytes) break; + output += char; + bytes += next; + } + return output; +} + +function redactJournalText(text: string, exactSecrets: string[]): string { + let redacted = redactCodeModeLogText(text); + for (const secret of exactSecrets) { + if (!secret) continue; + redacted = redacted.replaceAll(secret, "[REDACTED:capability]"); + } + return redacted.replace( + /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/giu, + "[REDACTED:capability]", + ); +} + +function isJournalEntry(value: unknown): value is CodeModeJournalEntry { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const entry = value as Partial; + return ( + typeof entry.timestamp === "string" && + typeof entry.code === "string" && + typeof entry.declarationHash === "string" && + isJournalOutcome(entry.outcome) && + Array.isArray(entry.diagnostics) && + entry.diagnostics.every(isJournalDiagnostic) && + (entry.recoveryClassification === "setup_like" || + entry.recoveryClassification === "side_effecting" || + entry.recoveryClassification === "unknown") && + (entry.logsStored === undefined || typeof entry.logsStored === "boolean") && + (entry.summary === undefined || typeof entry.summary === "string") + ); +} + +function isJournalOutcome(value: unknown): value is CodeModeJournalOutcome { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const outcome = value as Partial; + return outcome.ok === true || (outcome.ok === false && typeof outcome.code === "string"); +} + +function isJournalDiagnostic( + value: unknown, +): value is Pick { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const diagnostic = value as Partial; + return ( + typeof diagnostic.code === "string" && + typeof diagnostic.message === "string" && + (diagnostic.severity === "error" || + diagnostic.severity === "warning" || + diagnostic.severity === "info") + ); +} + +function rejectSymlinkPathComponents( + rootDir: string, + target: string, + includeTarget: boolean, +): void { + const resolvedRoot = resolve(rootDir); + rejectSymlinkRoot(resolvedRoot); + const rel = relative(resolvedRoot, resolve(target)); + if (rel.startsWith("..") || rel === "") return; + const parts = rel.split(/[\\/]+/u).filter(Boolean); + let current = resolvedRoot; + const limit = includeTarget ? parts.length : Math.max(0, parts.length - 1); + for (let index = 0; index < limit; index += 1) { + current = resolve(current, parts[index]!); + try { + if (lstatSync(current).isSymbolicLink()) { + throw new Error("Code Mode journal path must not contain symlinks."); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + } +} + +function rejectSymlinkRoot(rootDir: string): void { + try { + if (lstatSync(rootDir).isSymbolicLink()) { + throw new Error("Code Mode journal root must not be a symlink."); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } +} diff --git a/packages/core/src/code-mode/runner.ts b/packages/core/src/code-mode/runner.ts index 09e5f097..ca10fbd0 100644 --- a/packages/core/src/code-mode/runner.ts +++ b/packages/core/src/code-mode/runner.ts @@ -4,12 +4,16 @@ import { createCodeModeCapletsApi, listCodeModeCallableCaplets } from "./api"; import { codeModeDeclarationHash, generateCodeModeDeclarations } from "./declarations"; import { diagnoseCodeModeTypeScript } from "./diagnostics"; import { CodeModeLogStore, redactCodeModeLogText } from "./logs"; -import { QuickJsCodeModeSandbox, type CodeModeSandbox } from "./sandbox"; +import { classifyCodeModeRecovery, CodeModeJournalStore } from "./journal"; +import { QuickJsCodeModeSandbox, type CodeModeSandbox, type CodeModeSandboxInput } from "./sandbox"; +import { CODE_MODE_SESSION_COMPATIBILITY_VERSION, type CodeModeSessionManager } from "./sessions"; +import { CODE_MODE_PLATFORM_RUNTIME_SOURCE } from "./platform-runtime.generated"; import type { CodeModeDiagnostic, CodeModeLogEntry, CodeModeLogs, CodeModeRunEnvelope, + CodeModeRunMeta, JsonValue, } from "./types"; @@ -23,8 +27,11 @@ export type RunCodeModeInput = { timeoutMs?: number; maxTimeoutMs?: number; runtimeScope?: string; + sessionId?: string; logStore?: CodeModeLogStore; + journalStore?: CodeModeJournalStore; sandbox?: CodeModeSandbox; + sessionManager?: CodeModeSessionManager; returnedLogBytes?: number; }; @@ -35,13 +42,63 @@ export async function runCodeMode(input: RunCodeModeInput): Promise = { runId: randomUUID(), traceId: randomUUID(), declarationHash, timeoutMs, maxTimeoutMs, + sessionId: input.sessionManager ? null : (input.sessionId ?? null), + sessionStatus: null, + recoveryRef: null, }; + const meta = (): CodeModeRunMeta => ({ ...metaBase, durationMs: Date.now() - startedAt }); + + if (input.sessionId !== undefined && !input.sessionManager) { + return { + ok: false, + error: { + code: "SESSION_NOT_FOUND", + message: + "Code Mode session reuse is not available in this runtime. Omit sessionId to run one-shot.", + }, + diagnostics: [], + logs: emptyLogs(), + meta: meta(), + }; + } + + if ( + input.sessionManager && + input.sessionId && + input.sessionManager.isBusy(input.sessionId, sessionCompatibility) + ) { + return { + ok: false, + error: { + code: "SESSION_BUSY", + message: `Code Mode session ${input.sessionId} is already running.`, + }, + diagnostics: [], + logs: emptyLogs(), + meta: { + ...meta(), + sessionId: input.sessionId, + sessionStatus: null, + }, + }; + } const diagnostics = timeoutMs > maxTimeoutMs @@ -52,8 +109,54 @@ export async function runCodeMode(input: RunCodeModeInput): Promise diagnostic.severity === "error")) { + const diagnosticJournalScope = + input.sessionManager && input.sessionId + ? input.sessionManager.compatibilityKey(input.sessionId) + : undefined; + if (input.sessionManager && input.sessionId && diagnosticJournalScope === undefined) { + const recoveryRef = await recoveryRefForSession(input, input.sessionId); + return { + ok: false, + error: { + code: "SESSION_NOT_FOUND", + message: `Code Mode session ${input.sessionId} was not found.`, + }, + diagnostics, + logs: emptyLogs(), + meta: { + ...meta(), + sessionId: input.sessionId, + sessionStatus: null, + ...recoveryMeta(recoveryRef), + }, + }; + } + const recoveryRef = await journalRun(input, { + sessionId: input.sessionId ?? null, + code: input.code, + declarationHash, + diagnostics, + logs: emptyLogs(), + outcome: { + ok: false, + code: "diagnostic_blocked", + message: "Code Mode diagnostics failed before execution.", + }, + invokedCaplet: false, + sessionDisposedAfterRun: false, + journalScope: diagnosticJournalScope, + }); + if (input.sessionManager && input.sessionId) { + metaBase.sessionId = input.sessionId; + metaBase.sessionStatus = "reused"; + } + if (recoveryRef && !input.sessionManager) setRecoveryMeta(metaBase, recoveryRef); return { ok: false, error: { @@ -62,17 +165,19 @@ export async function runCodeMode(input: RunCodeModeInput): Promise input.logStore?.read(readInput) ?? { entries: [] }, + readRecovery: async (readInput) => + input.journalStore?.readRecovery(readInput) ?? { entries: [] }, }); - const sandbox = input.sandbox ?? new QuickJsCodeModeSandbox(); - const result = await sandbox.run({ + const sandboxInput: CodeModeSandboxInput = { code: input.code, capletIds: callable.map((caplet) => caplet.id), timeoutMs, @@ -80,6 +185,10 @@ export async function runCodeMode(input: RunCodeModeInput): Promise { + input.sessionManager?.recordSuccessfulCell(sessionId, code, declaration); + }, + }) + : undefined; + if (sessionRun && !sessionRun.ok) { + const recoveryRef = + sessionRun.error === "not_found" + ? await recoveryRefForSession(input, sessionRun.sessionId) + : undefined; + const code = + sessionRun.error === "not_found" + ? "SESSION_NOT_FOUND" + : sessionRun.error === "closed" + ? "SESSION_CLOSED" + : "SESSION_BUSY"; + const message = + sessionRun.error === "not_found" + ? `Code Mode session ${sessionRun.sessionId} was not found.` + : sessionRun.error === "closed" + ? "Code Mode session manager is closed." + : `Code Mode session ${sessionRun.sessionId} is already running.`; + return { + ok: false, + error: { + code, + message, + }, + diagnostics, + logs: emptyLogs(), + meta: { + ...meta(), + sessionId: sessionRun.sessionId, + sessionStatus: null, + ...recoveryMeta(recoveryRef), + }, + }; + } + const result = + sessionRun?.result ?? (await (input.sandbox ?? new QuickJsCodeModeSandbox()).run(sandboxInput)); + const sessionId = sessionRun?.sessionId ?? metaBase.sessionId ?? null; + const sessionStatus = sessionRun?.sessionStatus ?? metaBase.sessionStatus ?? null; + const exposeRecoveryRef = !input.sessionManager || sessionStatus === "created"; + metaBase.sessionId = sessionRun?.sessionDisposedAfterRun ? null : sessionId; + metaBase.sessionStatus = sessionRun?.sessionDisposedAfterRun ? null : sessionStatus; capturedLogs.push(...result.logs.map(redactLogEntry)); const logs = await buildLogs(capturedLogs, input.logStore, input.returnedLogBytes); if (!result.ok) { + const recoveryRef = await journalRun(input, { + sessionId, + code: input.code, + declarationHash, + diagnostics, + logs, + outcome: { ok: false, code: runtimeErrorCode(result.error), message: result.error }, + invokedCaplet, + sessionDisposedAfterRun: sessionRun?.sessionDisposedAfterRun ?? false, + journalScope: sessionRun?.compatibilityKey, + }); + if (recoveryRef && exposeRecoveryRef) setRecoveryMeta(metaBase, recoveryRef); return { ok: false, error: codeModeRuntimeError(result.error, result.stack), diagnostics, logs, - meta: { ...metaBase, durationMs: Date.now() - startedAt }, + meta: meta(), }; } @@ -125,6 +296,18 @@ export async function runCodeMode(input: RunCodeModeInput): Promise { + if (!input.journalStore || !run.sessionId) return undefined; + try { + const stored = await input.journalStore.store({ + sessionId: run.sessionId, + ...(run.journalScope === undefined ? {} : { journalScope: run.journalScope }), + code: run.code, + declarationHash: run.declarationHash, + outcome: run.outcome, + diagnostics: run.diagnostics, + recoveryClassification: classifyCodeModeRecovery({ + code: run.code, + invokedCaplet: run.invokedCaplet, + sessionDisposedAfterRun: run.sessionDisposedAfterRun, + }), + ...(run.logs.logRef ? { logRef: run.logs.logRef } : {}), + }); + return stored.recoveryRef; + } catch { + // Journal storage is recovery-only; never replace the primary Code Mode result. + return undefined; + } +} + +function setRecoveryMeta(metaBase: Omit, recoveryRef: string): void { + metaBase.recoveryRef = recoveryRef; +} + +async function recoveryRefForSession( + input: RunCodeModeInput, + sessionId: string, +): Promise { + const lookup = await input.journalStore?.lookupSession(sessionId); + return lookup?.recoveryRef; +} + +function recoveryMeta(recoveryRef: string | undefined): Pick { + if (!recoveryRef) { + return { recoveryRef: null }; + } + return { recoveryRef }; +} + function codeModeRuntimeError(message: string, stack?: string) { const location = userCodeLocation(stack); const stackPreview = diff --git a/packages/core/src/code-mode/runtime-api.d.ts b/packages/core/src/code-mode/runtime-api.d.ts index 2dcd402c..49dc722b 100644 --- a/packages/core/src/code-mode/runtime-api.d.ts +++ b/packages/core/src/code-mode/runtime-api.d.ts @@ -37,6 +37,7 @@ interface CapletHandle { interface DebugApi { readLogs(input: ReadLogsInput): Promise; + readRecovery(input: ReadCodeModeRecoveryInput): Promise; } type CapletCard = { @@ -124,11 +125,43 @@ type CompleteResult = unknown; type ReadLogsInput = { logRef: string; cursor?: string; limit?: number }; type ReadLogsResult = { entries: CodeModeLogEntry[]; nextCursor?: string }; +type ReadCodeModeRecoveryInput = { recoveryRef: string; cursor?: string; limit?: number }; +type CodeModeDiagnostic = { + code: string; + message: string; + severity: "error" | "warning" | "info"; + line?: number; + column?: number; +}; +type CodeModeRecoveryClassification = "setup_like" | "side_effecting" | "unknown"; +type CodeModeRecoveryEntry = { + timestamp: string; + code: string; + declarationHash: string; + outcome: { ok: true } | { ok: false; code: string; message: string }; + diagnostics: Array>; + recoveryClassification: CodeModeRecoveryClassification; + logsStored?: boolean; + summary?: string; +}; +type ReadCodeModeRecoveryResult = { entries: CodeModeRecoveryEntry[]; nextCursor?: string }; type CodeModeLogEntry = { level: "log" | "info" | "warn" | "error" | "debug"; message: string; timestamp: string; }; +type CodeModeSessionStatus = "created" | "reused"; +type CodeModeRunMeta = { + runId: string; + traceId: string; + declarationHash: string; + durationMs: number; + timeoutMs: number; + maxTimeoutMs: number; + sessionId?: string | null; + sessionStatus?: CodeModeSessionStatus | null; + recoveryRef?: string | null; +}; interface Console { log(...values: unknown[]): void; diff --git a/packages/core/src/code-mode/runtime-api.generated.ts b/packages/core/src/code-mode/runtime-api.generated.ts index 714a9fed..c2b82428 100644 --- a/packages/core/src/code-mode/runtime-api.generated.ts +++ b/packages/core/src/code-mode/runtime-api.generated.ts @@ -1,3 +1,3 @@ // Generated by scripts/generate-code-mode-runtime-api.mjs. Do not edit by hand. export const CODE_MODE_RUNTIME_API_DECLARATION = - 'type JsonPrimitive=string|number|boolean|null;type JsonValue=JsonPrimitive|JsonValue[]|{[key:string]:JsonValue};interface CapletHandle{readonly id:Id;/** Show this Caplet card,without tool/resource/prompt schemas. */ inspect():Promise>;/** Check backend readiness/auth;expected unavailable states return ok:false. */ check():Promise>;/** List tool summaries for the discovery pass;may be empty. */ tools(input?:PageInput):Promise>;/** Search tool summaries for the discovery pass;may be empty. */ searchTools(query:string,input?:PageInput):Promise>;/** Get schema,callSignature,types,examples;prefer outputSchema/outputTypeScript over observed hints. */ describeTool(name:string):Promise>;/** Call one tool;expected failures return ok:false. Filter bulky data in script before returning. */ callTool(name:string,args?:unknown):Promise>;/** List readable resources for the discovery pass;many backends expose none. */ resources(input?:PageInput):Promise>;/** Search readable resources for the discovery pass;many backends expose none. */ searchResources(query:string,input?:PageInput):Promise>;/** List resource templates for the discovery pass;many backends expose none. */ resourceTemplates(input?:PageInput):Promise>;/** Read one resource by URI;unsupported/missing resources return ok:false. */ readResource(uri:string):Promise>;/** List reusable prompts for the discovery pass;many backends expose none. */ prompts(input?:PageInput):Promise>;/** Search reusable prompts for the discovery pass;many backends expose none. */ searchPrompts(query:string,input?:PageInput):Promise>;/** Get one prompt by name and args;unsupported/missing prompts return ok:false. */ getPrompt(name:string,args?:unknown):Promise>;/** Complete a prompt or resource-template argument. */ complete(input:CompleteInput):Promise>;}interface DebugApi{readLogs(input:ReadLogsInput):Promise;}type CapletCard={id:Id;name:string;description:string;useWhen?:string;avoidWhen?:string;tags?:string[];backend?:unknown;};type PageInput={limit?:number;cursor?:string};type Page={items:T[];nextCursor?:string;truncated?:boolean};type CapletsResult=|{ok:true;data:T;meta?:CapletsMeta}|{ok:false;error:CapletsError;meta?:CapletsMeta};type CapletsMeta={[key:string]:unknown};type CapletsError={code:string;message:string;details?:unknown};type BackendCheckResult=unknown;type ToolSummary={/** Exact downstream tool identifier for describeTool(name)and callTool(name,args). */ name:string;title?:string;description?:string;/** Optional author-supplied hint for when to prefer this tool. */ useWhen?:string;/** Optional author-supplied hint for when to avoid this tool. */ avoidWhen?:string;/** True when the tool declares that it only reads data. */ readOnlyHint?:boolean;/** True when the tool declares that it may perform destructive writes. */ destructiveHint?:boolean;};type ToolDescriptor={id?:string;tool?:unknown;inputSchema?:unknown;outputSchema?:unknown;callSignature?:string;inputTypeScript?:string;outputTypeScript?:string;observedOutputShape?:ObservedOutputShape;examples?:unknown[];};type ObservedOutputShape={version:1;source:"observed";observedAt:string;sampleCount:number;typeScript:string;jsonShape:JsonShape;truncated:boolean;};type JsonShape=|{kind:"null"}|{kind:"boolean"}|{kind:"number"}|{kind:"string"}|{kind:"unknown"}|{kind:"array";element?:JsonShape;truncated?:boolean}|{kind:"object";fields:Record;truncated?:boolean;}|{kind:"union";variants:JsonShape[]};type ResourceSummary={uri?:string;name?:string;title?:string;description?:string};type ResourceTemplateSummary={uriTemplate?:string;name?:string;title?:string;description?:string;};type ResourceReadResult=unknown;type PromptSummary={name?:string;title?:string;description?:string};type PromptResult=unknown;type CompleteInput={ref:{type:"prompt";name:string}|{type:"resourceTemplate";uri:string};argument:{name:string;value:string};};type CompleteResult=unknown;type ReadLogsInput={logRef:string;cursor?:string;limit?:number};type ReadLogsResult={entries:CodeModeLogEntry[];nextCursor?:string};type CodeModeLogEntry={level:"log"|"info"|"warn"|"error"|"debug";message:string;timestamp:string;};interface Console{log(...values:unknown[]):void;info(...values:unknown[]):void;warn(...values:unknown[]):void;error(...values:unknown[]):void;debug(...values:unknown[]):void;}declare const console:Console;' as const; + 'type JsonPrimitive=string|number|boolean|null;type JsonValue=JsonPrimitive|JsonValue[]|{[key:string]:JsonValue};interface CapletHandle{readonly id:Id;/** Show this Caplet card,without tool/resource/prompt schemas. */ inspect():Promise>;/** Check backend readiness/auth;expected unavailable states return ok:false. */ check():Promise>;/** List tool summaries for the discovery pass;may be empty. */ tools(input?:PageInput):Promise>;/** Search tool summaries for the discovery pass;may be empty. */ searchTools(query:string,input?:PageInput):Promise>;/** Get schema,callSignature,types,examples;prefer outputSchema/outputTypeScript over observed hints. */ describeTool(name:string):Promise>;/** Call one tool;expected failures return ok:false. Filter bulky data in script before returning. */ callTool(name:string,args?:unknown):Promise>;/** List readable resources for the discovery pass;many backends expose none. */ resources(input?:PageInput):Promise>;/** Search readable resources for the discovery pass;many backends expose none. */ searchResources(query:string,input?:PageInput):Promise>;/** List resource templates for the discovery pass;many backends expose none. */ resourceTemplates(input?:PageInput):Promise>;/** Read one resource by URI;unsupported/missing resources return ok:false. */ readResource(uri:string):Promise>;/** List reusable prompts for the discovery pass;many backends expose none. */ prompts(input?:PageInput):Promise>;/** Search reusable prompts for the discovery pass;many backends expose none. */ searchPrompts(query:string,input?:PageInput):Promise>;/** Get one prompt by name and args;unsupported/missing prompts return ok:false. */ getPrompt(name:string,args?:unknown):Promise>;/** Complete a prompt or resource-template argument. */ complete(input:CompleteInput):Promise>;}interface DebugApi{readLogs(input:ReadLogsInput):Promise;readRecovery(input:ReadCodeModeRecoveryInput):Promise;}type CapletCard={id:Id;name:string;description:string;useWhen?:string;avoidWhen?:string;tags?:string[];backend?:unknown;};type PageInput={limit?:number;cursor?:string};type Page={items:T[];nextCursor?:string;truncated?:boolean};type CapletsResult=|{ok:true;data:T;meta?:CapletsMeta}|{ok:false;error:CapletsError;meta?:CapletsMeta};type CapletsMeta={[key:string]:unknown};type CapletsError={code:string;message:string;details?:unknown};type BackendCheckResult=unknown;type ToolSummary={/** Exact downstream tool identifier for describeTool(name)and callTool(name,args). */ name:string;title?:string;description?:string;/** Optional author-supplied hint for when to prefer this tool. */ useWhen?:string;/** Optional author-supplied hint for when to avoid this tool. */ avoidWhen?:string;/** True when the tool declares that it only reads data. */ readOnlyHint?:boolean;/** True when the tool declares that it may perform destructive writes. */ destructiveHint?:boolean;};type ToolDescriptor={id?:string;tool?:unknown;inputSchema?:unknown;outputSchema?:unknown;callSignature?:string;inputTypeScript?:string;outputTypeScript?:string;observedOutputShape?:ObservedOutputShape;examples?:unknown[];};type ObservedOutputShape={version:1;source:"observed";observedAt:string;sampleCount:number;typeScript:string;jsonShape:JsonShape;truncated:boolean;};type JsonShape=|{kind:"null"}|{kind:"boolean"}|{kind:"number"}|{kind:"string"}|{kind:"unknown"}|{kind:"array";element?:JsonShape;truncated?:boolean}|{kind:"object";fields:Record;truncated?:boolean;}|{kind:"union";variants:JsonShape[]};type ResourceSummary={uri?:string;name?:string;title?:string;description?:string};type ResourceTemplateSummary={uriTemplate?:string;name?:string;title?:string;description?:string;};type ResourceReadResult=unknown;type PromptSummary={name?:string;title?:string;description?:string};type PromptResult=unknown;type CompleteInput={ref:{type:"prompt";name:string}|{type:"resourceTemplate";uri:string};argument:{name:string;value:string};};type CompleteResult=unknown;type ReadLogsInput={logRef:string;cursor?:string;limit?:number};type ReadLogsResult={entries:CodeModeLogEntry[];nextCursor?:string};type ReadCodeModeRecoveryInput={recoveryRef:string;cursor?:string;limit?:number};type CodeModeDiagnostic={code:string;message:string;severity:"error"|"warning"|"info";line?:number;column?:number;};type CodeModeRecoveryClassification="setup_like"|"side_effecting"|"unknown";type CodeModeRecoveryEntry={timestamp:string;code:string;declarationHash:string;outcome:{ok:true}|{ok:false;code:string;message:string};diagnostics:Array>;recoveryClassification:CodeModeRecoveryClassification;logsStored?:boolean;summary?:string;};type ReadCodeModeRecoveryResult={entries:CodeModeRecoveryEntry[];nextCursor?:string};type CodeModeLogEntry={level:"log"|"info"|"warn"|"error"|"debug";message:string;timestamp:string;};type CodeModeSessionStatus="created"|"reused";type CodeModeRunMeta={runId:string;traceId:string;declarationHash:string;durationMs:number;timeoutMs:number;maxTimeoutMs:number;sessionId?:string|null;sessionStatus?:CodeModeSessionStatus|null;recoveryRef?:string|null;};interface Console{log(...values:unknown[]):void;info(...values:unknown[]):void;warn(...values:unknown[]):void;error(...values:unknown[]):void;debug(...values:unknown[]):void;}declare const console:Console;' as const; diff --git a/packages/core/src/code-mode/sandbox.ts b/packages/core/src/code-mode/sandbox.ts index ed275b8c..8fe60c76 100644 --- a/packages/core/src/code-mode/sandbox.ts +++ b/packages/core/src/code-mode/sandbox.ts @@ -6,6 +6,7 @@ import { type QuickJSHandle, type QuickJSRuntime, } from "quickjs-emscripten"; +import { randomUUID } from "node:crypto"; import ts from "typescript"; import { installCodeModePlatformHost } from "./platform-host"; import { CODE_MODE_PLATFORM_RUNTIME_SOURCE } from "./platform-runtime.generated"; @@ -28,7 +29,8 @@ export type CodeModeSandboxInvokeInput = { | "searchPrompts" | "getPrompt" | "complete" - | "readLogs"; + | "readLogs" + | "readRecovery"; args: unknown[]; }; @@ -39,6 +41,30 @@ export type CodeModeSandboxInput = { invoke: (input: CodeModeSandboxInvokeInput) => Promise; }; +const CODE_MODE_SANDBOX_METHODS = new Set([ + "inspect", + "check", + "tools", + "searchTools", + "describeTool", + "callTool", + "resources", + "searchResources", + "resourceTemplates", + "readResource", + "prompts", + "searchPrompts", + "getPrompt", + "complete", + "readLogs", + "readRecovery", +]); + +const CODE_MODE_DEBUG_METHODS = new Set([ + "readLogs", + "readRecovery", +]); + export type CodeModeSandboxResult = | { ok: true; value: unknown; logs: CodeModeLogEntry[] } | { ok: false; error: string; logs: CodeModeLogEntry[]; stack?: string }; @@ -47,10 +73,551 @@ export interface CodeModeSandbox { run(input: CodeModeSandboxInput): Promise; } +export interface CodeModeReplSession extends CodeModeSandbox { + dispose(): void; + isDisposed(): boolean; +} + export class QuickJsCodeModeSandbox implements CodeModeSandbox { async run(input: CodeModeSandboxInput): Promise { return await evaluateInQuickJs(input); } + + async createSession(): Promise { + const QuickJS = await getQuickJS(); + return new QuickJsCodeModeReplSession(QuickJS.newRuntime()); + } +} + +export class QuickJsCodeModeReplSession implements CodeModeReplSession { + #runtime: QuickJSRuntime; + #context: QuickJSContext; + #pendingDeferreds = new Set(); + #pendingInvokes = new Set(); + #platformHost: ReturnType; + #capletIds = new Set(); + #persistentNames = new Set(); + #invoke: (input: CodeModeSandboxInvokeInput) => Promise = async () => { + throw new Error("Code Mode invoke bridge is not initialized."); + }; + #deadlineMs = 0; + #timeoutMs = 0; + #logs: CodeModeLogEntry[] = []; + #disposed = false; + #running = false; + #disposeRequested = false; + #globalNameCheckpoint = new Set(); + #persistDescriptorCheckpoint = ""; + #checkpointToken = randomUUID(); + + constructor(runtime: QuickJSRuntime) { + this.#runtime = runtime; + this.#runtime.setMemoryLimit(64 * 1024 * 1024); + this.#runtime.setMaxStackSize(1 * 1024 * 1024); + this.#context = runtime.newContext(); + this.#installStableBridges(); + this.#platformHost = installCodeModePlatformHost(this.#context, this.#pendingDeferreds, {}); + this.#evalInitSource(); + } + + async run(input: CodeModeSandboxInput): Promise { + if (this.#disposed) { + return { ok: false, error: "Code Mode session is disposed.", logs: [] }; + } + if (this.#disposeRequested) { + return { ok: false, error: "Code Mode session is disposed.", logs: [] }; + } + if (this.#running) { + return { ok: false, error: "Code Mode session is already running.", logs: [] }; + } + this.#running = true; + const timeoutMs = Math.max(100, input.timeoutMs); + const deadlineMs = Date.now() + timeoutMs; + this.#runtime.setInterruptHandler(shouldInterruptAfterDeadline(deadlineMs)); + this.#logs = []; + let shouldDispose = false; + try { + this.#refreshInvokeBridge(input.invoke, deadlineMs, timeoutMs); + const bridgeResult = this.#context.evalCode( + buildBridgeRefreshSource(input.capletIds, [...this.#capletIds]), + ); + if (bridgeResult.error) { + const error = this.#context.dump(bridgeResult.error); + bridgeResult.error.dispose(); + const result: CodeModeSandboxResult = { + ok: false, + error: normalizeError(error, deadlineMs, timeoutMs), + logs: this.#logs, + ...optionalStack(stackFromDump(error)), + }; + shouldDispose = isTimeoutMessage(result.error, timeoutMs); + return result; + } + bridgeResult.value.dispose(); + this.#capletIds = new Set(input.capletIds); + const cell = buildSessionCellSource( + input.code, + input.capletIds, + [...this.#persistentNames], + this.#checkpointToken, + ); + const resetResult = this.#context.evalCode(buildPlatformResetSource()); + if (resetResult.error) { + const error = this.#context.dump(resetResult.error); + resetResult.error.dispose(); + const result: CodeModeSandboxResult = { + ok: false, + error: `Code Mode session state is corrupted: ${normalizeError(error, deadlineMs, timeoutMs)}`, + logs: this.#logs, + ...optionalStack(stackFromDump(error)), + }; + shouldDispose = true; + return result; + } + resetResult.value.dispose(); + this.#snapshotGlobalNames(); + this.#snapshotPersistDescriptors(); + this.#snapshotPersistentNames([...this.#persistentNames]); + let pendingInvokePersistentValuesChanged = false; + const result = await evaluateCellInContext({ + code: input.code, + compiledSource: cell.source, + context: this.#context, + runtime: this.#runtime, + pendingDeferreds: this.#pendingDeferreds, + pendingInvokes: this.#pendingInvokes, + deadlineMs, + timeoutMs, + logs: this.#logs, + session: true, + afterPendingInvokesDrained: () => { + pendingInvokePersistentValuesChanged = this.#persistentValuesDivergedFromPersist( + cell.persistentNames, + ); + this.#snapshotPersistentNames(cell.persistentNames); + }, + }); + if (result.ok) { + for (const name of cell.persistentNames) { + this.#persistentNames.add(name); + } + this.#snapshotPersistentNames(cell.persistentNames); + } else { + this.#restorePersistentNames([...this.#persistentNames]); + this.#rollbackNewPersistentNames(cell.newNames); + } + const platformRestored = result.ok ? this.#restorePlatformAfterRun() : true; + const persistenceDescriptorsOk = + result.ok && platformRestored ? this.#persistentDescriptorsOk(cell.persistentNames) : true; + const persistentGlobalDescriptorsOk = this.#persistentGlobalDescriptorsOk( + result.ok ? cell.persistentNames : [...this.#persistentNames], + ); + const hasGlobalNameAdditions = this.#hasGlobalNameAdditions( + result.ok ? cell.persistentNames : [], + ); + const directPersistAccess = this.#isPersistTainted(); + const persistDescriptorsChanged = this.#persistDescriptorsChanged( + result.ok ? cell.snapshotNames : [], + ); + const hasObjectLikePersistentState = this.#hasObjectLikePersistentState(); + shouldDispose = result.ok + ? pendingInvokePersistentValuesChanged || + this.#pendingDeferreds.size > 0 || + hasGlobalNameAdditions || + directPersistAccess || + persistDescriptorsChanged || + !this.#isGlobalExtensible() || + !persistentGlobalDescriptorsOk || + !persistenceDescriptorsOk || + !platformRestored + : isTimeoutMessage(result.error, timeoutMs) || + this.#pendingDeferreds.size > 0 || + cell.newNames.length > 0 || + hasGlobalNameAdditions || + directPersistAccess || + persistDescriptorsChanged || + hasObjectLikePersistentState || + !persistentGlobalDescriptorsOk || + !this.#isGlobalExtensible(); + if (!shouldDispose) { + this.#clearPendingDeferreds(); + } + return result; + } catch (error) { + const result: CodeModeSandboxResult = { + ok: false, + error: normalizeError(error, deadlineMs, timeoutMs), + logs: this.#logs, + ...optionalStack(stackFromError(error)), + }; + shouldDispose = true; + return result; + } finally { + this.#running = false; + if (!this.#disposed) { + this.#runtime.setInterruptHandler(() => false); + } + if (shouldDispose) { + this.dispose(); + } + if (this.#disposeRequested) { + this.#disposeNow(); + } + } + } + + dispose(): void { + if (this.#disposed) return; + if (this.#running) { + this.#disposeRequested = true; + return; + } + this.#disposeNow(); + } + + isDisposed(): boolean { + return this.#disposed; + } + + #disposeNow(): void { + if (this.#disposed) return; + this.#disposeRequested = false; + this.#disposed = true; + this.#platformHost.dispose(); + for (const deferred of this.#pendingDeferreds) { + if (deferred.alive) { + deferred.dispose(); + } + } + this.#pendingDeferreds.clear(); + this.#pendingInvokes.clear(); + this.#context.dispose(); + this.#runtime.dispose(); + } + + #installStableBridges(): void { + const logBridge = this.#context.newFunction("__caplets_log", (levelHandle, messageHandle) => { + this.#logs.push({ + level: logLevel(this.#context.getString(levelHandle)), + message: this.#context.getString(messageHandle), + timestamp: new Date().toISOString(), + }); + return this.#context.undefined; + }); + this.#context.setProp(this.#context.global, "__caplets_log", logBridge); + logBridge.dispose(); + const invokeBridge = createInvokeJsonBridge( + this.#context, + this.#pendingDeferreds, + this.#pendingInvokes, + () => this.#invoke, + () => this.#deadlineMs, + () => this.#timeoutMs, + (capletId) => this.#capletIds.has(capletId), + ); + this.#context.setProp(this.#context.global, "__caplets_invoke_json", invokeBridge); + invokeBridge.dispose(); + const activeCapletBridge = this.#context.newFunction( + "__caplets_is_active_id", + (capletIdHandle) => + this.#capletIds.has(this.#context.getString(capletIdHandle)) + ? this.#context.true + : this.#context.false, + ); + this.#context.setProp(this.#context.global, "__caplets_is_active_id", activeCapletBridge); + activeCapletBridge.dispose(); + const protectInvokeBridge = this.#context.evalCode( + [ + "Object.defineProperty(globalThis, '__caplets_log', { configurable: false, writable: false, value: globalThis.__caplets_log });", + "Object.defineProperty(globalThis, '__caplets_invoke_json', { configurable: false, writable: false, value: globalThis.__caplets_invoke_json });", + "Object.defineProperty(globalThis, '__caplets_is_active_id', { configurable: false, writable: false, value: globalThis.__caplets_is_active_id });", + ].join("\n"), + ); + if (protectInvokeBridge.error) { + const error = this.#context.dump(protectInvokeBridge.error); + protectInvokeBridge.error.dispose(); + throw new Error(errorMessage(error)); + } + protectInvokeBridge.value.dispose(); + } + + #evalInitSource(): void { + const result = this.#context.evalCode(buildSessionInitSource(this.#checkpointToken)); + if (result.error) { + const error = this.#context.dump(result.error); + result.error.dispose(); + throw new Error(errorMessage(error)); + } + result.value.dispose(); + } + + #refreshInvokeBridge( + invoke: (input: CodeModeSandboxInvokeInput) => Promise, + deadlineMs: number, + timeoutMs: number, + ): void { + this.#invoke = invoke; + this.#deadlineMs = deadlineMs; + this.#timeoutMs = timeoutMs; + } + + #rollbackNewPersistentNames(names: string[]): void { + if (names.length === 0 || this.#disposed) { + return; + } + const result = this.#context.evalCode( + names.map((name) => `(0, eval)(${JSON.stringify(`${name} = undefined;`)});`).join("\n"), + ); + if (result.error) { + result.error.dispose(); + return; + } + result.value.dispose(); + } + + #clearPendingDeferreds(): void { + this.#platformHost.dispose(); + for (const deferred of this.#pendingDeferreds) { + if (deferred.alive) { + deferred.dispose(); + } + } + this.#pendingDeferreds.clear(); + } + + #snapshotGlobalNames(): void { + if (this.#disposed) { + return; + } + const result = this.#context.evalCode("__caplets_global_keys()"); + if (result.error) { + result.error.dispose(); + return; + } + const names = this.#context.dump(result.value) as string[]; + this.#globalNameCheckpoint = new Set(names.filter((name) => !this.#isInternalGlobalName(name))); + result.value.dispose(); + } + + #snapshotPersistDescriptors(): void { + if (this.#disposed) { + return; + } + const result = this.#context.evalCode("__caplets_persist_descriptor_fingerprint()"); + if (result.error) { + result.error.dispose(); + this.#persistDescriptorCheckpoint = ""; + return; + } + this.#persistDescriptorCheckpoint = String(this.#context.dump(result.value)); + result.value.dispose(); + } + + #persistDescriptorsChanged(allowedNames: string[] = []): boolean { + if (this.#disposed) { + return false; + } + const result = this.#context.evalCode( + `__caplets_persist_descriptor_fingerprint(${JSON.stringify(allowedNames)})`, + ); + if (result.error) { + result.error.dispose(); + return true; + } + const fingerprint = String(this.#context.dump(result.value)); + result.value.dispose(); + return ( + fingerprint !== + filterPersistDescriptorFingerprint(this.#persistDescriptorCheckpoint, allowedNames) + ); + } + + #hasGlobalNameAdditions(allowedNames: string[] = []): boolean { + if (this.#disposed) { + return false; + } + const result = this.#context.evalCode("__caplets_global_keys()"); + if (result.error) { + result.error.dispose(); + return true; + } + const names = this.#context.dump(result.value) as string[]; + const allowedTokens = new Set(allowedNames.map((name) => `string:${name}`)); + const hasAdditions = names + .filter((name) => !this.#isInternalGlobalName(name)) + .filter((name) => !allowedTokens.has(name)) + .some((name) => !this.#globalNameCheckpoint.has(name)); + result.value.dispose(); + return hasAdditions; + } + + #isGlobalExtensible(): boolean { + if (this.#disposed) { + return false; + } + const result = this.#context.evalCode("__caplets_is_global_extensible()"); + if (result.error) { + result.error.dispose(); + return false; + } + const isExtensible = this.#context.dump(result.value) === true; + result.value.dispose(); + return isExtensible; + } + + #restorePlatformAfterRun(): boolean { + if (this.#disposed) { + return false; + } + const result = this.#context.evalCode(buildPlatformResetSource()); + if (result.error) { + result.error.dispose(); + return false; + } + result.value.dispose(); + return true; + } + + #persistentDescriptorsOk(names: string[]): boolean { + if (names.length === 0 || this.#disposed) { + return true; + } + const result = this.#context.evalCode( + `__caplets_persist_descriptors_ok(${JSON.stringify(this.#checkpointToken)}, ${JSON.stringify(names)})`, + ); + if (result.error) { + result.error.dispose(); + return false; + } + const ok = this.#context.dump(result.value) === true; + result.value.dispose(); + return ok; + } + + #isPersistTainted(): boolean { + if (this.#disposed) { + return false; + } + const result = this.#context.evalCode( + `__caplets_persist_is_tainted(${JSON.stringify(this.#checkpointToken)})`, + ); + if (result.error) { + result.error.dispose(); + return true; + } + const tainted = this.#context.dump(result.value) === true; + result.value.dispose(); + return tainted; + } + + #hasObjectLikePersistentState(): boolean { + if (this.#disposed || this.#persistentNames.size === 0) { + return false; + } + const result = this.#context.evalCode( + `__caplets_persist_has_object_like_values(${JSON.stringify(this.#checkpointToken)}, ${JSON.stringify([...this.#persistentNames])})`, + ); + if (result.error) { + result.error.dispose(); + return true; + } + const hasObjectLike = this.#context.dump(result.value) === true; + result.value.dispose(); + return hasObjectLike; + } + + #persistentGlobalDescriptorsOk(names: string[]): boolean { + if (names.length === 0 || this.#disposed) { + return true; + } + const result = this.#context.evalCode( + `__caplets_persist_global_descriptors_ok(${JSON.stringify(this.#checkpointToken)}, ${JSON.stringify(names)})`, + ); + if (result.error) { + result.error.dispose(); + return false; + } + const ok = this.#context.dump(result.value) === true; + result.value.dispose(); + return ok; + } + + #isInternalGlobalName(name: string): boolean { + return ( + name === "string:__caplets_log" || + name === "string:__caplets_invoke_json" || + name === "string:__caplets_is_active_id" || + name === "string:__caplets_restore_platform" || + name === "string:__caplets_global_keys" || + name === "string:__caplets_assert_active_id" || + name === "string:__caplets_handle" || + name === "string:__caplets_persist" || + name === "string:__caplets_persist_descriptor_fingerprint" || + name === "string:__caplets_persist_descriptors_ok" || + name === "string:__caplets_persist_is_tainted" || + name === "string:__caplets_persist_has_object_like_values" || + name === "string:__caplets_persist_global_descriptors_ok" || + name === "string:__caplets_get_persist" || + name === "string:__caplets_set_persist" || + name === "string:__caplets_snapshot_persist" || + name === "string:__caplets_checkpoint_value" || + name === "string:__caplets_observe_promise" || + name === "string:__caplets_json_parse" + ); + } + + #snapshotPersistentNames(names: string[]): void { + if (names.length === 0 || this.#disposed) { + return; + } + const result = this.#context.evalCode( + `globalThis.__caplets_snapshot_persist(${JSON.stringify(this.#checkpointToken)}, ${JSON.stringify(names)});`, + ); + if (result.error) { + result.error.dispose(); + return; + } + result.value.dispose(); + } + + #persistentValuesDivergedFromPersist(names: string[]): boolean { + if (this.#disposed || names.length === 0) { + return false; + } + const result = this.#context.evalCode( + `(${JSON.stringify(names)}).some((name) => !Object.is((0, eval)(name), globalThis.__caplets_get_persist(${JSON.stringify(this.#checkpointToken)}, name)))`, + ); + if (result.error) { + result.error.dispose(); + return true; + } + try { + return this.#context.dump(result.value) === true; + } finally { + result.value.dispose(); + } + } + + #restorePersistentNames(names: string[]): void { + if (names.length === 0 || this.#disposed) { + return; + } + const result = this.#context.evalCode( + names + .map((name) => + [ + `globalThis.__caplets_set_persist(${JSON.stringify(this.#checkpointToken)}, ${JSON.stringify(name)}, globalThis.__caplets_checkpoint_value(${JSON.stringify(this.#checkpointToken)}, ${JSON.stringify(name)}));`, + `(0, eval)(${JSON.stringify(`${name} = globalThis.__caplets_checkpoint_value(${JSON.stringify(this.#checkpointToken)}, ${JSON.stringify(name)});`)});`, + ].join("\n"), + ) + .join("\n"), + ); + if (result.error) { + result.error.dispose(); + return; + } + result.value.dispose(); + } } async function evaluateInQuickJs(input: CodeModeSandboxInput): Promise { @@ -89,60 +656,20 @@ async function evaluateInQuickJs(input: CodeModeSandboxInput): Promise; + pendingInvokes: Set; + deadlineMs: number; + timeoutMs: number; + logs: CodeModeLogEntry[]; + session: boolean; + afterPendingInvokesDrained?: () => void; +}; + +async function evaluateCellInContext( + input: EvaluateCellInContextInput, +): Promise { + const observerResult = input.context.evalCode(buildPromiseObserverSource()); + if (observerResult.error) { + const error = input.context.dump(observerResult.error); + observerResult.error.dispose(); + return { + ok: false, + error: normalizeError(error, input.deadlineMs, input.timeoutMs), + logs: input.logs, + ...optionalStack(stackFromDump(error)), + }; + } + observerResult.value.dispose(); + + const evaluated = input.context.evalCode( + input.compiledSource ?? + (input.session + ? buildSessionCellSource(input.code, input.capletIds ?? []).source + : buildExecutionSource(input.code, input.capletIds ?? [])), + ); + if (evaluated.error) { + const error = input.context.dump(evaluated.error); + evaluated.error.dispose(); + return { + ok: false, + error: normalizeError(error, input.deadlineMs, input.timeoutMs), + logs: input.logs, + ...optionalStack(stackFromDump(error)), + }; + } + + const resultName = `__caplets_result_${randomUUID().replace(/-/gu, "_")}`; + input.context.setProp(input.context.global, resultName, evaluated.value); + evaluated.value.dispose(); + + const stateResult = input.context.evalCode(buildPromiseStateSource(JSON.stringify(resultName))); + cleanupTemporaryGlobals(input.context, JSON.stringify(resultName)); + if (stateResult.error) { + const error = input.context.dump(stateResult.error); + stateResult.error.dispose(); + return { + ok: false, + error: normalizeError(error, input.deadlineMs, input.timeoutMs), + logs: input.logs, + ...optionalStack(stackFromDump(error)), + }; + } + + const stateHandle = stateResult.value; + try { + if (input.session) { + if (input.pendingInvokes.size > 0) { + await drainAsync( + input.context, + input.runtime, + input.pendingInvokes, + input.deadlineMs, + input.timeoutMs, + ); + await drainAsync( + input.context, + input.runtime, + input.pendingDeferreds, + input.deadlineMs, + input.timeoutMs, + ); + await Promise.resolve(); + drainJobs(input.context, input.runtime, input.deadlineMs, input.timeoutMs); + input.afterPendingInvokesDrained?.(); + } else { + drainJobs(input.context, input.runtime, input.deadlineMs, input.timeoutMs); + } + const earlyResult = readSettledPromiseState(input.context, stateHandle, input.logs); + if (earlyResult) return earlyResult; + } + await drainAsync( + input.context, + input.runtime, + input.pendingDeferreds, + input.deadlineMs, + input.timeoutMs, + ); + const settled = readProp(input.context, stateHandle, "settled") === true; + if (!settled) { + return { ok: false, error: timeoutMessage(input.timeoutMs), logs: input.logs }; + } + return ( + readSettledPromiseState(input.context, stateHandle, input.logs) ?? { + ok: false, + error: timeoutMessage(input.timeoutMs), + logs: input.logs, + } + ); + } finally { + stateHandle.dispose(); + } +} + +function cleanupTemporaryGlobals(context: QuickJSContext, ...names: string[]): void { + if (names.length === 0) { + return; + } + const result = context.evalCode(names.map((name) => `delete globalThis[${name}];`).join("\n")); + if (result.error) { + result.error.dispose(); + return; + } + result.value.dispose(); +} + +function readSettledPromiseState( + context: QuickJSContext, + stateHandle: QuickJSHandle, + logs: CodeModeLogEntry[], +): CodeModeSandboxResult | undefined { + const settled = readProp(context, stateHandle, "settled") === true; + if (!settled) { + return undefined; + } + const error = readProp(context, stateHandle, "error"); + if (typeof error !== "undefined") { + return { + ok: false, + error: errorMessage(error), + logs, + }; + } + return { ok: true, value: readProp(context, stateHandle, "value"), logs }; +} + function createInvokeBridge( context: QuickJSContext, pendingDeferreds: Set, @@ -179,7 +853,12 @@ function createInvokeBridge( const args = context.dump(argsHandle) as unknown[]; const deferred = context.newPromise(); pendingDeferreds.add(deferred); - deferred.settled.finally(() => pendingDeferreds.delete(deferred)); + deferred.settled.finally(() => { + pendingDeferreds.delete(deferred); + if (deferred.alive) { + deferred.dispose(); + } + }); void invoke({ capletId, method, args }).then( (value) => { @@ -211,6 +890,117 @@ function createInvokeBridge( }); } +function createInvokeJsonBridge( + context: QuickJSContext, + pendingDeferreds: Set, + pendingInvokes: Set, + invoke: () => (input: CodeModeSandboxInvokeInput) => Promise, + deadlineMs: () => number, + timeoutMs: () => number, + isCapletActive: (capletId: string) => boolean, +): QuickJSHandle { + return context.newFunction("__caplets_invoke_json", (capletHandle, methodHandle, argsHandle) => { + const capletId = context.getString(capletHandle); + const method = context.getString(methodHandle); + const args = context.dump(argsHandle) as unknown[]; + const deferred = context.newPromise(); + pendingDeferreds.add(deferred); + pendingInvokes.add(deferred); + deferred.settled.finally(() => { + pendingDeferreds.delete(deferred); + pendingInvokes.delete(deferred); + if (deferred.alive) { + deferred.dispose(); + } + }); + + const debugMethod = CODE_MODE_DEBUG_METHODS.has(method as CodeModeSandboxInvokeInput["method"]); + const debugCapletActive = capletId === "debug" && isCapletActive("debug"); + if ( + !isCodeModeSandboxMethod(method) || + (capletId === "debug" && !debugMethod && !debugCapletActive) || + (capletId !== "debug" && debugMethod) + ) { + const errorHandle = context.newError( + `Method ${method} is not available in this Code Mode session cell.`, + ); + deferred.reject(errorHandle); + errorHandle.dispose(); + return deferred.handle; + } + + if (!(capletId === "debug" && debugMethod) && !isCapletActive(capletId)) { + const errorHandle = context.newError( + `Caplet ${capletId} is not available in this Code Mode session cell.`, + ); + deferred.reject(errorHandle); + errorHandle.dispose(); + return deferred.handle; + } + + void invoke()({ capletId, method, args }).then( + (value) => { + if (!deferred.alive) { + return; + } + let valueHandle: QuickJSHandle | undefined; + try { + const serialized = JSON.stringify(value); + const parsed = context.evalCode( + `globalThis.__caplets_json_parse(${JSON.stringify(serialized ?? "null")})`, + ); + if (parsed.error) { + const error = context.dump(parsed.error); + parsed.error.dispose(); + throw new Error(errorMessage(error)); + } + valueHandle = parsed.value; + deferred.resolve(valueHandle); + } catch (error) { + const errorHandle = context.newError(errorMessage(error)); + deferred.reject(errorHandle); + errorHandle.dispose(); + } finally { + valueHandle?.dispose(); + } + }, + (error) => { + if (!deferred.alive) { + return; + } + const message = + Date.now() >= deadlineMs() ? timeoutMessage(timeoutMs()) : errorMessage(error); + const errorHandle = context.newError(message); + deferred.reject(errorHandle); + errorHandle.dispose(); + }, + ); + return deferred.handle; + }); +} + +function isCodeModeSandboxMethod(method: string): method is CodeModeSandboxInvokeInput["method"] { + return CODE_MODE_SANDBOX_METHODS.has(method as CodeModeSandboxInvokeInput["method"]); +} + +function filterPersistDescriptorFingerprint(fingerprint: string, allowedNames: string[]): string { + if (!fingerprint || allowedNames.length === 0) { + return fingerprint; + } + const allowedTokens = new Set(allowedNames.map((name) => `string:${name}`)); + return fingerprint + .split("|") + .filter((entry) => { + const token = entry.startsWith("string:") + ? entry.slice(0, entry.indexOf(":", "string:".length)) + : entry.startsWith("symbol:") + ? entry.slice(0, entry.indexOf(":", "symbol:".length)) + : entry; + return !allowedTokens.has(token); + }) + .join("|"); +} + function buildExecutionSource(code: string, capletIds: string[]): string { const javascript = ts.transpileModule(code, { compilerOptions: { @@ -246,12 +1036,812 @@ function buildExecutionSource(code: string, capletIds: string[]): string { ), "caplets.debug = caplets.debug || {};", "caplets.debug.readLogs = (input) => __invoke('debug', 'readLogs', [input]);", + "caplets.debug.readRecovery = (input) => __invoke('debug', 'readRecovery', [input]);", "(async () => {", javascript, "})()", ].join("\n"); } +function buildSessionInitSource(checkpointToken: string): string { + return [ + CODE_MODE_PLATFORM_RUNTIME_SOURCE, + "const __caplets_restore_platform = (() => {", + " const defineProperty = Object.defineProperty.bind(Object);", + " const defineProperties = Object.defineProperties.bind(Object);", + " const getOwnPropertyDescriptors = Object.getOwnPropertyDescriptors.bind(Object);", + " const getPrototypeOf = Object.getPrototypeOf.bind(Object);", + " const setPrototypeOf = Object.setPrototypeOf.bind(Object);", + " const isExtensible = Object.isExtensible.bind(Object);", + " const ownKeys = Reflect.ownKeys.bind(Reflect);", + " const hasOwn = Function.prototype.call.bind(Object.prototype.hasOwnProperty);", + " const arrayPush = Function.prototype.call.bind(Array.prototype.push);", + " const globalSymbolIds = new Map();", + " let nextGlobalSymbolId = 1;", + " const keyToken = (key) => {", + " if (typeof key !== 'symbol') return `string:${key}`;", + " if (!globalSymbolIds.has(key)) globalSymbolIds.set(key, nextGlobalSymbolId++);", + " return `symbol:${globalSymbolIds.get(key)}`;", + " };", + " const platformGlobals = [];", + " const globalPrototypeSnapshot = getPrototypeOf(globalThis);", + " const platformExtraObjects = [", + " ['AsyncFunction.prototype', getPrototypeOf(async function() {})],", + " ['AsyncFunctionConstructor', getPrototypeOf(async function() {}).constructor],", + " ['GeneratorFunction.prototype', getPrototypeOf(function*() {})],", + " ['GeneratorFunctionConstructor', getPrototypeOf(function*() {}).constructor],", + " ['AsyncGeneratorFunction.prototype', getPrototypeOf(async function*() {})],", + " ['AsyncGeneratorFunctionConstructor', getPrototypeOf(async function*() {}).constructor],", + " ['GeneratorObjectPrototype', getPrototypeOf((function*() {})())],", + " ['GeneratorObjectPrototypeParent', getPrototypeOf(getPrototypeOf((function*() {})()))],", + " ['AsyncGeneratorObjectPrototype', getPrototypeOf((async function*() {})())],", + " ['AsyncGeneratorObjectPrototypeParent', getPrototypeOf(getPrototypeOf((async function*() {})()))],", + " ['ArrayIteratorPrototype', getPrototypeOf([][Symbol.iterator]())],", + " ['IteratorPrototype', getPrototypeOf(getPrototypeOf([][Symbol.iterator]()))],", + " ['StringIteratorPrototype', getPrototypeOf(''[Symbol.iterator]())],", + " ['RegExpStringIteratorPrototype', getPrototypeOf(''.matchAll(/(?:)/g))],", + " ['RegExpStringIteratorPrototypeParent', getPrototypeOf(getPrototypeOf(''.matchAll(/(?:)/g)))],", + " ['MapIteratorPrototype', getPrototypeOf(new Map()[Symbol.iterator]())],", + " ['SetIteratorPrototype', getPrototypeOf(new Set()[Symbol.iterator]())],", + " ['TypedArrayConstructor', getPrototypeOf(Int8Array)],", + " ['TypedArrayPrototype', getPrototypeOf(Int8Array.prototype)],", + " ['AsyncFunctionPrototypeParent', getPrototypeOf(getPrototypeOf(async function() {}))],", + " ['GeneratorFunctionPrototypeParent', getPrototypeOf(getPrototypeOf(function*() {}))],", + " ['AsyncGeneratorFunctionPrototypeParent', getPrototypeOf(getPrototypeOf(async function*() {}))],", + " ];", + " const platformExtraSnapshot = Object.create(null);", + " const platformExtraPrototypeSnapshot = Object.create(null);", + " const platformExtraExtensibleSnapshot = Object.create(null);", + " const platformGlobalNames = ownKeys(globalThis);", + " for (const name of platformGlobalNames) {", + " const record = {", + " name,", + " descriptor: Object.getOwnPropertyDescriptor(globalThis, name),", + " value: globalThis[name],", + " objectPrototype: undefined,", + " properties: undefined,", + " prototypeProperties: undefined,", + " prototypeChain: undefined,", + " extensible: undefined,", + " prototypeExtensible: undefined,", + " };", + " const value = globalThis[name];", + " if (value !== globalThis && ((typeof value === 'object' && value !== null) || typeof value === 'function')) {", + " record.objectPrototype = getPrototypeOf(value);", + " record.properties = getOwnPropertyDescriptors(value);", + " record.extensible = isExtensible(value);", + " const prototype = value.prototype;", + " if ((typeof prototype === 'object' && prototype !== null) || typeof prototype === 'function') {", + " record.prototypeChain = getPrototypeOf(prototype);", + " record.prototypeProperties = getOwnPropertyDescriptors(prototype);", + " record.prototypeExtensible = isExtensible(prototype);", + " }", + " }", + " arrayPush(platformGlobals, record);", + " }", + " for (const [name, value] of platformExtraObjects) {", + " if ((typeof value === 'object' && value !== null) || typeof value === 'function') {", + " platformExtraSnapshot[name] = getOwnPropertyDescriptors(value);", + " platformExtraPrototypeSnapshot[name] = getPrototypeOf(value);", + " platformExtraExtensibleSnapshot[name] = isExtensible(value);", + " }", + " }", + " const globalKeys = () => {", + " const keys = ownKeys(globalThis);", + " const tokens = [];", + " for (let index = 0; index < keys.length; index += 1) {", + " const key = keys[index];", + " arrayPush(tokens, keyToken(key));", + " }", + " return tokens;", + " };", + " const restore = () => {", + " if (getPrototypeOf(globalThis) !== globalPrototypeSnapshot) {", + " try { setPrototypeOf(globalThis, globalPrototypeSnapshot); } catch { throw new Error('Code Mode global prototype chain is not restorable'); }", + " }", + " for (const record of platformGlobals) {", + " const name = record.name;", + " try {", + " defineProperty(globalThis, name, record.descriptor);", + " const descriptors = record.properties;", + " const value = globalThis[name];", + " if (descriptors && ((typeof value === 'object' && value !== null) || typeof value === 'function')) {", + " if (record.extensible && !isExtensible(value)) throw new Error(`Code Mode platform global ${String(name)} is non-extensible`);", + " if (getPrototypeOf(value) !== record.objectPrototype) {", + " try { setPrototypeOf(value, record.objectPrototype); } catch { throw new Error(`Code Mode platform global ${String(name)} prototype chain is not restorable`); }", + " }", + " for (const key of ownKeys(value)) {", + " if (!hasOwn(descriptors, key)) {", + " try { delete value[key]; } catch {}", + " if (hasOwn(value, key)) throw new Error(`Code Mode platform global ${String(name)} has non-restorable property ${String(key)}`);", + " }", + " }", + " defineProperties(value, descriptors);", + " const prototypeDescriptors = record.prototypeProperties;", + " const prototype = value.prototype;", + " if (prototypeDescriptors && ((typeof prototype === 'object' && prototype !== null) || typeof prototype === 'function')) {", + " if (record.prototypeExtensible && !isExtensible(prototype)) throw new Error(`Code Mode platform prototype ${String(name)}.prototype is non-extensible`);", + " if (getPrototypeOf(prototype) !== record.prototypeChain) {", + " try { setPrototypeOf(prototype, record.prototypeChain); } catch { throw new Error(`Code Mode platform prototype ${String(name)}.prototype chain is not restorable`); }", + " }", + " for (const key of ownKeys(prototype)) {", + " if (!hasOwn(prototypeDescriptors, key)) {", + " try { delete prototype[key]; } catch {}", + " if (hasOwn(prototype, key)) throw new Error(`Code Mode platform prototype ${String(name)}.prototype has non-restorable property ${String(key)}`);", + " }", + " }", + " defineProperties(prototype, prototypeDescriptors);", + " }", + " }", + " } catch (error) {", + " throw new Error(`Code Mode platform global ${String(name)} is not restorable: ${String(error && error.message ? error.message : error)}`);", + " }", + " }", + " for (const [name, value] of platformExtraObjects) {", + " const descriptors = platformExtraSnapshot[name];", + " if (!descriptors || !((typeof value === 'object' && value !== null) || typeof value === 'function')) continue;", + " if (platformExtraExtensibleSnapshot[name] && !isExtensible(value)) throw new Error(`Code Mode platform intrinsic ${name} is non-extensible`);", + " if (getPrototypeOf(value) !== platformExtraPrototypeSnapshot[name]) {", + " try { setPrototypeOf(value, platformExtraPrototypeSnapshot[name]); } catch { throw new Error(`Code Mode platform intrinsic ${name} prototype chain is not restorable`); }", + " }", + " for (const key of ownKeys(value)) {", + " if (!hasOwn(descriptors, key)) {", + " try { delete value[key]; } catch {}", + " if (hasOwn(value, key)) throw new Error(`Code Mode platform intrinsic ${name} has non-restorable property ${String(key)}`);", + " }", + " }", + " defineProperties(value, descriptors);", + " }", + " };", + " return { restore, globalKeys, isGlobalExtensible: () => isExtensible(globalThis) };", + "})();", + "Object.defineProperty(globalThis, '__caplets_restore_platform', { configurable: false, writable: false, value: __caplets_restore_platform.restore });", + "Object.defineProperty(globalThis, '__caplets_global_keys', { configurable: false, writable: false, value: __caplets_restore_platform.globalKeys });", + "Object.defineProperty(globalThis, '__caplets_is_global_extensible', { configurable: false, writable: false, value: __caplets_restore_platform.isGlobalExtensible });", + "Object.defineProperty(globalThis, '__caplets_assert_active_id', { configurable: false, writable: false, value: (capletId) => {", + " if (capletId === 'debug' && !__caplets_is_active_id(capletId)) return;", + " if (!__caplets_is_active_id(capletId)) throw new Error(`Caplet ${capletId} is not available in this Code Mode session cell.`);", + "} });", + "Object.defineProperty(globalThis, '__caplets_handle', { configurable: false, writable: false, value: (capletId) => ({", + " id: capletId,", + " inspect: () => (__caplets_assert_active_id(capletId), __caplets_invoke_json(capletId, 'inspect', [])),", + " check: () => (__caplets_assert_active_id(capletId), __caplets_invoke_json(capletId, 'check', [])),", + " tools: (input) => (__caplets_assert_active_id(capletId), __caplets_invoke_json(capletId, 'tools', [input])),", + " searchTools: (query, input) => (__caplets_assert_active_id(capletId), __caplets_invoke_json(capletId, 'searchTools', [query, input])),", + " describeTool: (name) => (__caplets_assert_active_id(capletId), __caplets_invoke_json(capletId, 'describeTool', [name])),", + " callTool: (name, args) => (__caplets_assert_active_id(capletId), __caplets_invoke_json(capletId, 'callTool', [name, args])),", + " resources: (input) => (__caplets_assert_active_id(capletId), __caplets_invoke_json(capletId, 'resources', [input])),", + " searchResources: (query, input) => (__caplets_assert_active_id(capletId), __caplets_invoke_json(capletId, 'searchResources', [query, input])),", + " resourceTemplates: (input) => (__caplets_assert_active_id(capletId), __caplets_invoke_json(capletId, 'resourceTemplates', [input])),", + " readResource: (uri) => (__caplets_assert_active_id(capletId), __caplets_invoke_json(capletId, 'readResource', [uri])),", + " prompts: (input) => (__caplets_assert_active_id(capletId), __caplets_invoke_json(capletId, 'prompts', [input])),", + " searchPrompts: (query, input) => (__caplets_assert_active_id(capletId), __caplets_invoke_json(capletId, 'searchPrompts', [query, input])),", + " getPrompt: (name, args) => (__caplets_assert_active_id(capletId), __caplets_invoke_json(capletId, 'getPrompt', [name, args])),", + " complete: (input) => (__caplets_assert_active_id(capletId), __caplets_invoke_json(capletId, 'complete', [input])),", + "}) });", + "(() => {", + ` const checkpointToken = ${JSON.stringify(checkpointToken)};`, + " const persistBacking = Object.create(null);", + " let persistTainted = false;", + " const checkpointState = { current: Object.create(null) };", + " const ownKeys = Reflect.ownKeys.bind(Reflect);", + " const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor.bind(Object);", + " const defineProperty = Object.defineProperty.bind(Object);", + " const getPrototypeOf = Object.getPrototypeOf.bind(Object);", + " const reflectGet = Reflect.get.bind(Reflect);", + " const reflectSet = Reflect.set.bind(Reflect);", + " const reflectDefineProperty = Reflect.defineProperty.bind(Reflect);", + " const reflectDeleteProperty = Reflect.deleteProperty.bind(Reflect);", + " const reflectSetPrototypeOf = Reflect.setPrototypeOf.bind(Reflect);", + " const reflectPreventExtensions = Reflect.preventExtensions.bind(Reflect);", + " const reflectGetOwnPropertyDescriptor = Reflect.getOwnPropertyDescriptor.bind(Reflect);", + " const reflectOwnKeys = Reflect.ownKeys.bind(Reflect);", + " const hasOwn = Function.prototype.call.bind(Object.prototype.hasOwnProperty);", + " const objectIds = new WeakMap();", + " let nextObjectId = 1;", + " const keyToken = (key) => typeof key === 'symbol' ? `symbol:${String(key)}` : `string:${key}`;", + " const valueToken = (value) => {", + " const type = typeof value;", + " if ((type === 'object' && value !== null) || type === 'function') {", + " if (!objectIds.has(value)) objectIds.set(value, nextObjectId++);", + " return `${type}:${objectIds.get(value)}`;", + " }", + " return `${type}:${String(value)}`;", + " };", + " const descriptorToken = (descriptor) => {", + " if (!descriptor) return 'missing';", + " return [", + " hasOwn(descriptor, 'value') ? 'data' : 'accessor',", + " descriptor.writable === true ? 'w' : 'nw',", + " descriptor.configurable === true ? 'c' : 'nc',", + " descriptor.enumerable === true ? 'e' : 'ne',", + " typeof descriptor.get,", + " typeof descriptor.set,", + " ].join(':');", + " };", + " const assertToken = (token) => {", + " if (token !== checkpointToken) throw new Error('Code Mode persistence checkpoint token is invalid.');", + " };", + " const persistProxy = new Proxy(persistBacking, {", + " get(target, key, receiver) { persistTainted = true; return reflectGet(target, key, receiver); },", + " set(target, key, value, receiver) { persistTainted = true; return reflectSet(target, key, value, receiver); },", + " defineProperty(target, key, descriptor) { persistTainted = true; return reflectDefineProperty(target, key, descriptor); },", + " deleteProperty(target, key) { persistTainted = true; return reflectDeleteProperty(target, key); },", + " setPrototypeOf(target, prototype) { persistTainted = true; return reflectSetPrototypeOf(target, prototype); },", + " preventExtensions(target) { persistTainted = true; return reflectPreventExtensions(target); },", + " getOwnPropertyDescriptor(target, key) { persistTainted = true; return reflectGetOwnPropertyDescriptor(target, key); },", + " ownKeys(target) { persistTainted = true; return reflectOwnKeys(target); },", + " getPrototypeOf(target) { persistTainted = true; return getPrototypeOf(target); },", + " });", + " Object.defineProperty(globalThis, '__caplets_persist', { configurable: false, writable: false, value: persistProxy });", + " Object.defineProperty(globalThis, '__caplets_get_persist', { configurable: false, writable: false, value: (token, name) => {", + " assertToken(token);", + " return hasOwn(persistBacking, name) ? persistBacking[name] : undefined;", + " } });", + " Object.defineProperty(globalThis, '__caplets_set_persist', { configurable: false, writable: false, value: (token, name, value) => {", + " assertToken(token);", + " defineProperty(persistBacking, name, { value, writable: true, configurable: true, enumerable: true });", + " return value;", + " } });", + " Object.defineProperty(globalThis, '__caplets_persist_is_tainted', { configurable: false, writable: false, value: (token) => {", + " assertToken(token);", + " return persistTainted;", + " } });", + " Object.defineProperty(globalThis, '__caplets_persist_has_object_like_values', { configurable: false, writable: false, value: (token, names) => {", + " assertToken(token);", + " for (const name of names) {", + " if (!hasOwn(persistBacking, name)) continue;", + " const value = persistBacking[name];", + " if ((typeof value === 'object' && value !== null) || typeof value === 'function') return true;", + " }", + " return false;", + " } });", + " Object.defineProperty(globalThis, '__caplets_persist_global_descriptors_ok', { configurable: false, writable: false, value: (token, names) => {", + " assertToken(token);", + " for (const name of names) {", + " const descriptor = getOwnPropertyDescriptor(globalThis, name);", + " if (!descriptor || !hasOwn(descriptor, 'value') || descriptor.writable !== true) return false;", + " }", + " return true;", + " } });", + " Object.defineProperty(globalThis, '__caplets_persist_descriptor_fingerprint', { configurable: false, writable: false, value: (allowedNames = []) => {", + " const allowed = new Set(allowedNames.map((name) => `string:${name}`));", + " const prototype = getPrototypeOf(persistBacking);", + " const keys = ownKeys(persistBacking).map((key) => keyToken(key)).filter((token) => !allowed.has(token)).sort();", + " return keys.map((token) => {", + " const key = token.startsWith('symbol:') ? ownKeys(persistBacking).find((candidate) => keyToken(candidate) === token) : token.slice('string:'.length);", + " const descriptor = getOwnPropertyDescriptor(persistBacking, key);", + " const value = descriptor && hasOwn(descriptor, 'value') ? valueToken(descriptor.value) : 'accessor';", + " return `${token}:${descriptorToken(descriptor)}:${value}`;", + " }).concat(`[[Prototype]]:${valueToken(prototype)}`).join('|');", + " } });", + " Object.defineProperty(globalThis, '__caplets_persist_descriptors_ok', { configurable: false, writable: false, value: (token, names) => {", + " assertToken(token);", + " if (getPrototypeOf(persistBacking) !== null) return false;", + " for (const name of names) {", + " const descriptor = getOwnPropertyDescriptor(persistBacking, name);", + " if (!descriptor || !hasOwn(descriptor, 'value')) return false;", + " if (descriptor.get || descriptor.set || descriptor.writable !== true || descriptor.configurable !== true) return false;", + " }", + " return true;", + " } });", + " Object.defineProperty(globalThis, '__caplets_snapshot_persist', { configurable: false, writable: false, value: (token, names) => {", + " assertToken(token);", + " const next = Object.create(null);", + " for (const name of names) {", + " try { next[name] = (0, eval)(name); }", + " catch { next[name] = hasOwn(globalThis, name) ? globalThis[name] : hasOwn(persistBacking, name) ? persistBacking[name] : undefined; }", + " }", + " checkpointState.current = next;", + " } });", + " Object.defineProperty(globalThis, '__caplets_checkpoint_value', { configurable: false, writable: false, value: (token, name) => {", + " assertToken(token);", + " return checkpointState.current[name];", + " } });", + "})();", + "Object.defineProperty(globalThis, '__caplets_json_parse', { configurable: false, writable: false, value: JSON.parse.bind(JSON) });", + ].join("\n"); +} + +function buildBridgeRefreshSource(_capletIds: string[], _previousCapletIds: string[] = []): string { + return ""; +} + +function buildSessionCellSource( + code: string, + capletIds: string[], + existingNames: string[] = [], + checkpointToken = "", +): { source: string; persistentNames: string[]; newNames: string[]; snapshotNames: string[] } { + const javascript = ts.transpileModule(code, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + }).outputText; + const split = splitPersistentPrelude(javascript, existingNames, checkpointToken); + return { + source: [ + split.prelude, + "(async () => {", + '"use strict";', + buildPlatformResetSource(), + buildPlatformShadowRestoreSource(existingNames, checkpointToken), + buildCellCapletsSource(capletIds), + split.body, + split.postlude, + "})()", + ].join("\n"), + persistentNames: split.persistentNames, + newNames: split.newNames, + snapshotNames: split.snapshotNames, + }; +} + +function buildPlatformResetSource(): string { + return "globalThis.__caplets_restore_platform();"; +} + +function buildPlatformShadowRestoreSource( + existingNames: string[], + checkpointToken: string, +): string { + return existingNames + .map( + (name) => + `(0, eval)(${JSON.stringify(`${name} = globalThis.__caplets_get_persist(${JSON.stringify(checkpointToken)}, ${JSON.stringify(name)});`)});`, + ) + .join("\n"); +} + +function buildCellCapletsSource(capletIds: string[]): string { + return [ + "const caplets = {};", + ...capletIds.map( + (capletId) => + `caplets[${JSON.stringify(capletId)}] = __caplets_handle(${JSON.stringify(capletId)});`, + ), + "caplets.debug = caplets.debug ?? {};", + "caplets.debug.readLogs = (input) => __caplets_invoke_json('debug', 'readLogs', [input]);", + "caplets.debug.readRecovery = (input) => __caplets_invoke_json('debug', 'readRecovery', [input]);", + ].join("\n"); +} + +function splitPersistentPrelude( + javascript: string, + existingNames: string[] = [], + checkpointToken = "", +): { + prelude: string; + body: string; + postlude: string; + persistentNames: string[]; + newNames: string[]; + snapshotNames: string[]; +} { + const source = ts.createSourceFile( + "/caplets-code-mode/session-cell.js", + javascript, + ts.ScriptTarget.ES2022, + true, + ); + const ranges: Array<{ start: number; end: number; prelude: string; body: string }> = []; + const names = collectPersistentBindingNames(source); + const lexicalNames = collectTopLevelLexicalBindingNames(source); + const allNames = [...new Set([...existingNames, ...names])]; + const snapshotNames = allNames.filter((name) => !lexicalNames.has(name)); + const returnTempName = uniqueInternalName("__caplets_return", allNames); + const postlude = snapshotNames + .map((name) => persistentBindingPostlude(name, checkpointToken)) + .join("\n"); + for (const statement of source.statements) { + collectPersistentVarRewriteRanges(statement, source, lexicalNames, ranges); + collectPersistentReturnRanges( + statement, + source, + snapshotNames, + ranges, + returnTempName, + checkpointToken, + ); + collectPersistentFinallyRanges(statement, snapshotNames, ranges, checkpointToken); + } + ranges.sort((left, right) => left.start - right.start); + const prelude = [ + ...allNames.map( + (name) => + `var ${name} = globalThis.__caplets_get_persist(${JSON.stringify(checkpointToken)}, ${JSON.stringify(name)});`, + ), + ...ranges.map((range) => range.prelude), + ] + .filter(Boolean) + .join("\n"); + let body = ""; + let cursor = 0; + for (const range of ranges) { + body += javascript.slice(cursor, range.start); + body += range.body; + cursor = range.end; + } + body += javascript.slice(cursor); + if (postlude) { + body += `\n${postlude}`; + } + return { + prelude, + body, + postlude, + persistentNames: allNames, + newNames: names.filter((name) => !existingNames.includes(name)), + snapshotNames, + }; +} + +function collectPersistentReturnRanges( + node: ts.Node, + source: ts.SourceFile, + snapshotNames: string[], + ranges: Array<{ start: number; end: number; prelude: string; body: string }>, + returnTempName: string, + checkpointToken: string, + shadowedNames: ReadonlySet = new Set(), +): void { + if (snapshotNames.length === 0) { + return; + } + if (ts.isFunctionLike(node) || ts.isClassLike(node)) { + return; + } + const declared = lexicalNamesDeclaredByNode(node); + const activeShadowedNames = + declared.size === 0 ? shadowedNames : new Set([...shadowedNames, ...declared]); + if (ts.isReturnStatement(node)) { + const expression = node.expression?.getText(source); + const postludeExpression = snapshotNames + .filter((name) => !activeShadowedNames.has(name)) + .map((name) => persistentBindingExpression(name, checkpointToken)) + .join(", "); + ranges.push({ + start: node.getFullStart(), + end: node.end, + prelude: "", + body: returnWithPersistenceExpression(expression, postludeExpression, returnTempName), + }); + return; + } + ts.forEachChild(node, (child) => + collectPersistentReturnRanges( + child, + source, + snapshotNames, + ranges, + returnTempName, + checkpointToken, + activeShadowedNames, + ), + ); +} + +function lexicalNamesDeclaredByNode(node: ts.Node): Set { + const names = new Set(); + if (ts.isBlock(node) || ts.isCaseClause(node) || ts.isDefaultClause(node)) { + for (const statement of node.statements) { + if (ts.isVariableStatement(statement) && !isVarDeclarationList(statement.declarationList)) { + for (const name of declarationListBindingNames(statement.declarationList)) { + names.add(name); + } + } + if ((ts.isClassDeclaration(statement) || ts.isEnumDeclaration(statement)) && statement.name) { + names.add(statement.name.text); + } + if (ts.isFunctionDeclaration(statement) && statement.name) { + names.add(statement.name.text); + } + } + } + if (ts.isSwitchStatement(node)) { + for (const clause of node.caseBlock.clauses) { + for (const statement of clause.statements) { + if (ts.isVariableStatement(statement) && !isVarDeclarationList(statement.declarationList)) { + for (const name of declarationListBindingNames(statement.declarationList)) { + names.add(name); + } + } + if ( + (ts.isClassDeclaration(statement) || ts.isEnumDeclaration(statement)) && + statement.name + ) { + names.add(statement.name.text); + } + if (ts.isFunctionDeclaration(statement) && statement.name) { + names.add(statement.name.text); + } + } + } + } + if (ts.isCatchClause(node) && node.variableDeclaration) { + for (const name of bindingNames(node.variableDeclaration.name)) { + names.add(name); + } + } + if ( + (ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node)) && + node.initializer && + ts.isVariableDeclarationList(node.initializer) && + !isVarDeclarationList(node.initializer) + ) { + for (const name of declarationListBindingNames(node.initializer)) { + names.add(name); + } + } + return names; +} + +function returnWithPersistenceExpression( + expression: string | undefined, + postludeExpression: string, + returnTempName: string, +): string { + if (!postludeExpression) { + return expression ? `return ${expression};` : "return;"; + } + return expression + ? `return (async (${returnTempName}) => { await Promise.resolve(); ${postludeExpression}; return ${returnTempName}; })((${expression}));` + : `await Promise.resolve();\nreturn void (${postludeExpression});`; +} + +function uniqueInternalName(baseName: string, unavailableNames: string[]): string { + const unavailable = new Set(unavailableNames); + let name = baseName; + while (unavailable.has(name)) { + name = `_${name}`; + } + return name; +} + +function collectPersistentVarRewriteRanges( + node: ts.Node, + source: ts.SourceFile, + lexicalNames: ReadonlySet, + ranges: Array<{ start: number; end: number; prelude: string; body: string }>, +): void { + if (ts.isFunctionLike(node) || ts.isClassLike(node)) { + return; + } + if (ts.isVariableStatement(node) && isVarDeclarationList(node.declarationList)) { + const names = declarationListBindingNames(node.declarationList); + if (!names.some((name) => lexicalNames.has(name))) { + ranges.push({ + start: node.getFullStart(), + end: node.end, + prelude: "", + body: varStatementAssignments(node.declarationList, source), + }); + } + return; + } + if ( + (ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node)) && + node.initializer && + ts.isVariableDeclarationList(node.initializer) && + isVarDeclarationList(node.initializer) + ) { + const names = declarationListBindingNames(node.initializer); + if (!names.some((name) => lexicalNames.has(name))) { + ranges.push({ + start: node.initializer.getStart(source), + end: node.initializer.end, + prelude: "", + body: forInitializerAssignment(node.initializer, source), + }); + } + } + ts.forEachChild(node, (child) => + collectPersistentVarRewriteRanges(child, source, lexicalNames, ranges), + ); +} + +function collectPersistentFinallyRanges( + node: ts.Node, + snapshotNames: string[], + ranges: Array<{ start: number; end: number; prelude: string; body: string }>, + checkpointToken: string, + shadowedNames: ReadonlySet = new Set(), +): void { + if (snapshotNames.length === 0) { + return; + } + if (ts.isFunctionLike(node) || ts.isClassLike(node)) { + return; + } + const declared = lexicalNamesDeclaredByNode(node); + const activeShadowedNames = + declared.size === 0 ? shadowedNames : new Set([...shadowedNames, ...declared]); + if (ts.isTryStatement(node) && node.finallyBlock) { + const finallyDeclared = lexicalNamesDeclaredByNode(node.finallyBlock); + const finallyShadowedNames = + finallyDeclared.size === 0 + ? activeShadowedNames + : new Set([...activeShadowedNames, ...finallyDeclared]); + const postlude = snapshotNames + .filter((name) => !finallyShadowedNames.has(name)) + .map((name) => persistentBindingPostlude(name, checkpointToken)) + .join("\n"); + if (postlude) { + ranges.push({ + start: node.finallyBlock.end - 1, + end: node.finallyBlock.end - 1, + prelude: "", + body: `\n${postlude}\n`, + }); + } + } + ts.forEachChild(node, (child) => + collectPersistentFinallyRanges( + child, + snapshotNames, + ranges, + checkpointToken, + activeShadowedNames, + ), + ); +} + +function collectPersistentBindingNames(source: ts.SourceFile): string[] { + const names = new Set(); + const visit = (node: ts.Node): void => { + if (ts.isFunctionDeclaration(node) && node.name && node.parent === source) { + names.add(node.name.text); + } + if (node !== source && (ts.isFunctionLike(node) || ts.isClassLike(node))) { + return; + } + if (ts.isVariableStatement(node)) { + collectVarDeclarationListNames(node.declarationList, names); + } + if ( + (ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node)) && + node.initializer && + ts.isVariableDeclarationList(node.initializer) + ) { + collectVarDeclarationListNames(node.initializer, names); + } + ts.forEachChild(node, visit); + }; + visit(source); + return [...names]; +} + +function collectTopLevelLexicalBindingNames(source: ts.SourceFile): Set { + const names = new Set(); + for (const statement of source.statements) { + if (ts.isVariableStatement(statement) && !isVarDeclarationList(statement.declarationList)) { + for (const name of declarationListBindingNames(statement.declarationList)) { + names.add(name); + } + } + if ((ts.isClassDeclaration(statement) || ts.isEnumDeclaration(statement)) && statement.name) { + names.add(statement.name.text); + } + } + return names; +} + +function collectVarDeclarationListNames( + declarationList: ts.VariableDeclarationList, + names: Set, +): void { + const isVar = (ts.getCombinedNodeFlags(declarationList) & ts.NodeFlags.BlockScoped) === 0; + if (!isVar) { + return; + } + for (const declaration of declarationList.declarations) { + for (const name of bindingNames(declaration.name)) { + names.add(name); + } + } +} + +function isVarDeclarationList(declarationList: ts.VariableDeclarationList): boolean { + return (ts.getCombinedNodeFlags(declarationList) & ts.NodeFlags.BlockScoped) === 0; +} + +function declarationListBindingNames(declarationList: ts.VariableDeclarationList): string[] { + return declarationList.declarations.flatMap((declaration) => bindingNames(declaration.name)); +} + +function varStatementAssignments( + declarationList: ts.VariableDeclarationList, + source: ts.SourceFile, +): string { + return declarationList.declarations + .map((declaration) => + declaration.initializer + ? assignmentStatement(declaration.name, declaration.initializer, source) + : "", + ) + .filter(Boolean) + .join("\n"); +} + +function forInitializerAssignment( + declarationList: ts.VariableDeclarationList, + source: ts.SourceFile, +): string { + const assignments = declarationList.declarations.map((declaration) => + declaration.initializer + ? assignmentExpression(declaration.name, declaration.initializer, source) + : declaration.name.getText(source), + ); + return assignments.join(", "); +} + +function persistentBindingPostlude(name: string, checkpointToken: string): string { + return [ + `globalThis.__caplets_set_persist(${JSON.stringify(checkpointToken)}, ${JSON.stringify(name)}, ${name});`, + `(0, eval)(${JSON.stringify(`${name} = globalThis.__caplets_get_persist(${JSON.stringify(checkpointToken)}, ${JSON.stringify(name)});`)});`, + ].join("\n"); +} + +function persistentBindingExpression(name: string, checkpointToken: string): string { + return [ + `globalThis.__caplets_set_persist(${JSON.stringify(checkpointToken)}, ${JSON.stringify(name)}, ${name})`, + `(0, eval)(${JSON.stringify(`${name} = globalThis.__caplets_get_persist(${JSON.stringify(checkpointToken)}, ${JSON.stringify(name)});`)})`, + ].join(", "); +} + +function bindingNames(name: ts.BindingName): string[] { + if (ts.isIdentifier(name)) { + return [name.text]; + } + return name.elements.flatMap((element) => { + if (ts.isOmittedExpression(element)) { + return []; + } + return bindingNames(element.name); + }); +} + +function assignmentStatement( + name: ts.BindingName, + initializer: ts.Expression, + source: ts.SourceFile, +): string { + const expression = assignmentExpression(name, initializer, source); + return ts.isObjectBindingPattern(name) ? `(${expression});` : `${expression};`; +} + +function assignmentExpression( + name: ts.BindingName, + initializer: ts.Expression, + source: ts.SourceFile, +): string { + return `${name.getText(source)} = ${initializer.getText(source)}`; +} + +function buildPromiseObserverSource(): string { + return [ + "(() => {", + " const then = Promise.prototype.then;", + " const formatError = function(e) {", + " if (e && typeof e === 'object' && typeof e.message === 'string') return e.message;", + " return String(e);", + " };", + " try { Object.defineProperty(globalThis, '__caplets_observe_promise', { configurable: false, writable: false, value: function(p) {", + " var s = { settled: false, value: void 0, error: void 0 };", + " then.call(p, function(value) { s.value = value; s.settled = true; },", + " function(error) { s.error = formatError(error); s.settled = true; });", + " return s;", + " } }); } catch {}", + "})()", + ].join("\n"); +} + +function buildPromiseStateSource(resultName: string): string { + return [`globalThis.__caplets_observe_promise(globalThis[${resultName}])`].join("\n"); +} + async function drainAsync( context: QuickJSContext, runtime: QuickJSRuntime, @@ -323,6 +1913,10 @@ function timeoutMessage(timeoutMs: number): string { return `Code Mode execution timed out after ${timeoutMs}ms`; } +function isTimeoutMessage(message: string, timeoutMs: number): boolean { + return message === timeoutMessage(timeoutMs); +} + function normalizeError(error: unknown, deadlineMs: number, timeoutMs: number): string { const message = errorMessage(error); return Date.now() >= deadlineMs && /\binterrupted\b/iu.test(message) diff --git a/packages/core/src/code-mode/sessions.ts b/packages/core/src/code-mode/sessions.ts new file mode 100644 index 00000000..2576cb07 --- /dev/null +++ b/packages/core/src/code-mode/sessions.ts @@ -0,0 +1,284 @@ +import { randomUUID } from "node:crypto"; +import { + QuickJsCodeModeSandbox, + type CodeModeReplSession, + type CodeModeSandboxResult, + type CodeModeSandboxInput, +} from "./sandbox"; +import { CodeModeDiagnosticsSession } from "./diagnostics"; +import type { CodeModeSessionStatus } from "./types"; + +export const CODE_MODE_SESSION_COMPATIBILITY_VERSION = 1; +export const DEFAULT_CODE_MODE_SESSION_TTL_MS = 30 * 60 * 1_000; +export const DEFAULT_CODE_MODE_SESSION_LIMIT = 32; + +export type CodeModeSessionCompatibility = { + declarationHash: string; + platformRuntimeHash: string; + runtimeScope: string; + version?: number; +}; + +export type CodeModeSessionRunInput = CodeModeSandboxInput & { + sessionId?: string; + compatibility: CodeModeSessionCompatibility; + onSuccessfulCell?: (sessionId: string, code: string) => void; +}; + +export type CodeModeSessionRunResult = + | { + ok: true; + sessionId: string; + sessionStatus: CodeModeSessionStatus; + sessionDisposedAfterRun: boolean; + compatibilityKey: string; + result: CodeModeSandboxResult; + } + | { + ok: false; + sessionId: string; + sessionStatus: null; + error: "not_found" | "busy" | "closed"; + }; + +export type CodeModeSessionManagerOptions = { + idGenerator?: () => string; + now?: () => number; + ttlMs?: number; + maxSessions?: number; + sandboxFactory?: () => CodeModeReplSessionFactory; +}; + +export type CodeModeReplSessionFactory = { + createSession(): Promise; +}; + +type SessionRecord = { + id: string; + session: CodeModeReplSession; + diagnosticsSession: CodeModeDiagnosticsSession; + compatibilityKey: string; + lastUsedAt: number; + busy: boolean; +}; + +export class CodeModeSessionManager { + readonly ttlMs: number; + readonly maxSessions: number; + #sessions = new Map(); + #idGenerator: () => string; + #now: () => number; + #sandboxFactory: () => CodeModeReplSessionFactory; + #closed = false; + + constructor(options: CodeModeSessionManagerOptions = {}) { + this.ttlMs = options.ttlMs ?? DEFAULT_CODE_MODE_SESSION_TTL_MS; + this.maxSessions = options.maxSessions ?? DEFAULT_CODE_MODE_SESSION_LIMIT; + this.#idGenerator = options.idGenerator ?? randomUUID; + this.#now = options.now ?? Date.now; + this.#sandboxFactory = options.sandboxFactory ?? (() => new QuickJsCodeModeSandbox()); + } + + async run(input: CodeModeSessionRunInput): Promise { + if (this.#closed) { + return { + ok: false, + sessionId: input.sessionId ?? "", + sessionStatus: null, + error: "closed", + }; + } + this.#evictExpired(); + const compatibilityKey = compatibilityKeyFor(input.compatibility); + const requestedSessionId = input.sessionId; + let record: SessionRecord | undefined; + let sessionStatus: CodeModeSessionStatus; + + if (requestedSessionId) { + record = this.#sessions.get(requestedSessionId); + if (!record) { + return { + ok: false, + sessionId: requestedSessionId, + sessionStatus: null, + error: "not_found", + }; + } + if (record.busy) { + return { + ok: false, + sessionId: requestedSessionId, + sessionStatus: null, + error: "busy", + }; + } + if (record.compatibilityKey !== compatibilityKey) { + this.#disposeRecord(record.id); + return { + ok: false, + sessionId: requestedSessionId, + sessionStatus: null, + error: "not_found", + }; + } else { + sessionStatus = "reused"; + } + } else { + const sessionId = this.#nextSessionId(); + record = await this.#createRecord(sessionId, compatibilityKey); + if (!record) return this.#closedResult(sessionId); + sessionStatus = "created"; + } + + this.#evictToLimit(record.id); + record.busy = true; + try { + const result = await record.session.run({ + ...input, + invoke: async (invokeInput) => { + if (this.#closed) { + throw new Error("Code Mode session manager is closed."); + } + return await input.invoke(invokeInput); + }, + }); + record.lastUsedAt = this.#now(); + const sessionDisposedAfterRun = record.session.isDisposed(); + if (sessionDisposedAfterRun) { + this.#sessions.delete(record.id); + } else if (result.ok) { + input.onSuccessfulCell?.(record.id, input.code); + } + return { + ok: true, + sessionId: record.id, + sessionStatus, + sessionDisposedAfterRun, + compatibilityKey: record.compatibilityKey, + result, + }; + } finally { + record.busy = false; + this.#evictToLimit(record.id); + } + } + + close(): void { + this.#closed = true; + for (const record of this.#sessions.values()) { + record.session.dispose(); + } + this.#sessions.clear(); + } + + has(sessionId: string): boolean { + this.#evictExpired(); + return this.#sessions.has(sessionId); + } + + compatibilityKey(sessionId: string): string | undefined { + this.#evictExpired(); + return this.#sessions.get(sessionId)?.compatibilityKey; + } + + diagnosticsSession( + sessionId: string, + compatibility: CodeModeSessionCompatibility, + ): CodeModeDiagnosticsSession | undefined { + this.#evictExpired(); + const record = this.#sessions.get(sessionId); + if (!record) return undefined; + const compatibilityKey = compatibilityKeyFor(compatibility); + if (record.compatibilityKey !== compatibilityKey) { + this.#disposeRecord(sessionId); + return undefined; + } + return record.diagnosticsSession; + } + + isBusy(sessionId: string, compatibility: CodeModeSessionCompatibility): boolean { + this.#evictExpired(); + const record = this.#sessions.get(sessionId); + if (!record) return false; + const compatibilityKey = compatibilityKeyFor(compatibility); + if (record.compatibilityKey !== compatibilityKey) return false; + return record.busy; + } + + recordSuccessfulCell(sessionId: string, code: string, declaration = ""): void { + this.#sessions.get(sessionId)?.diagnosticsSession.recordSuccessfulCell(code, declaration); + } + + async #createRecord(id: string, compatibilityKey: string): Promise { + if (this.#closed) { + return undefined; + } + const session = await this.#sandboxFactory().createSession(); + if (this.#closed) { + session.dispose(); + return undefined; + } + const record: SessionRecord = { + id, + session, + diagnosticsSession: new CodeModeDiagnosticsSession(), + compatibilityKey, + lastUsedAt: this.#now(), + busy: false, + }; + this.#sessions.set(id, record); + return record; + } + + #nextSessionId(): string { + let id = this.#idGenerator(); + while (this.#sessions.has(id)) { + id = this.#idGenerator(); + } + return id; + } + + #evictExpired(): void { + const now = this.#now(); + for (const record of this.#sessions.values()) { + if (!record.busy && now - record.lastUsedAt > this.ttlMs) { + this.#disposeRecord(record.id); + } + } + } + + #evictToLimit(protectedId: string): void { + while (this.#sessions.size > this.maxSessions) { + const candidate = [...this.#sessions.values()] + .filter((record) => !record.busy && record.id !== protectedId) + .sort((left, right) => left.lastUsedAt - right.lastUsedAt)[0]; + if (!candidate) return; + this.#disposeRecord(candidate.id); + } + } + + #disposeRecord(id: string): void { + const record = this.#sessions.get(id); + if (!record) return; + record.session.dispose(); + this.#sessions.delete(id); + } + + #closedResult(sessionId: string): CodeModeSessionRunResult { + return { + ok: false, + sessionId, + sessionStatus: null, + error: "closed", + }; + } +} + +function compatibilityKeyFor(input: CodeModeSessionCompatibility): string { + return JSON.stringify({ + declarationHash: input.declarationHash, + platformRuntimeHash: input.platformRuntimeHash, + runtimeScope: input.runtimeScope, + version: input.version ?? CODE_MODE_SESSION_COMPATIBILITY_VERSION, + }); +} diff --git a/packages/core/src/code-mode/tool.ts b/packages/core/src/code-mode/tool.ts index 5c967ac2..85489746 100644 --- a/packages/core/src/code-mode/tool.ts +++ b/packages/core/src/code-mode/tool.ts @@ -1,4 +1,8 @@ import { z } from "zod"; +import type { CodeModeRunMeta } from "./types"; + +export const CODE_MODE_SESSION_ID_DESCRIPTION = + "Optional Code Mode session identifier. Omit to create a fresh reusable session; pass a known live session ID from meta.sessionId to reuse existing REPL state. Unknown or unavailable session IDs fail before code execution instead of starting an empty context."; export const codeModeRunInputSchema = z.object({ code: z.string().describe("TypeScript Code Mode source to execute."), @@ -8,6 +12,7 @@ export const codeModeRunInputSchema = z.object({ .positive() .optional() .describe("Optional execution timeout in milliseconds."), + sessionId: z.string().min(1).optional().describe(CODE_MODE_SESSION_ID_DESCRIPTION), }); export const codeModeRunParamsSchema = codeModeRunInputSchema.shape; @@ -25,6 +30,11 @@ export function codeModeRunInputJsonSchema(): Record { minimum: 1, description: "Optional execution timeout in milliseconds.", }, + sessionId: { + type: "string", + minLength: 1, + description: CODE_MODE_SESSION_ID_DESCRIPTION, + }, }, required: ["code"], additionalProperties: false, @@ -34,3 +44,17 @@ export function codeModeRunInputJsonSchema(): Record { export function isCodeModeRunRequest(value: unknown): boolean { return codeModeRunInputSchema.safeParse(value).success; } + +export function emptyCodeModeRunMeta(): CodeModeRunMeta { + return { + runId: "", + traceId: "", + declarationHash: "", + durationMs: 0, + timeoutMs: 0, + maxTimeoutMs: 0, + sessionId: null, + sessionStatus: null, + recoveryRef: null, + }; +} diff --git a/packages/core/src/code-mode/types.ts b/packages/core/src/code-mode/types.ts index c96a8f55..3637ddb8 100644 --- a/packages/core/src/code-mode/types.ts +++ b/packages/core/src/code-mode/types.ts @@ -30,6 +30,8 @@ export type CodeModeDiagnostic = { column?: number; }; +export type CodeModeSessionStatus = "created" | "reused"; + export type CodeModeRunMeta = { runId: string; traceId: string; @@ -37,6 +39,9 @@ export type CodeModeRunMeta = { durationMs: number; timeoutMs: number; maxTimeoutMs: number; + sessionId?: string | null; + sessionStatus?: CodeModeSessionStatus | null; + recoveryRef?: string | null; }; export type CodeModeRunError = { @@ -121,3 +126,33 @@ export type ReadLogsResult = { entries: CodeModeLogEntry[]; nextCursor?: string; }; + +export type ReadCodeModeRecoveryInput = { + recoveryRef: string; + cursor?: string; + limit?: number; +}; + +export type CodeModeRecoveryClassification = "setup_like" | "side_effecting" | "unknown"; + +export type CodeModeRecoveryEntry = { + timestamp: string; + code: string; + declarationHash: string; + outcome: + | { ok: true } + | { + ok: false; + code: string; + message: string; + }; + diagnostics: Array>; + recoveryClassification: CodeModeRecoveryClassification; + logsStored?: boolean; + summary?: string; +}; + +export type ReadCodeModeRecoveryResult = { + entries: CodeModeRecoveryEntry[]; + nextCursor?: string; +}; diff --git a/packages/core/src/native/options.ts b/packages/core/src/native/options.ts index 760b7eff..1896b937 100644 --- a/packages/core/src/native/options.ts +++ b/packages/core/src/native/options.ts @@ -1,20 +1,19 @@ import { CapletsError } from "../errors"; -import type { CapletsServerEnv, CapletsServerInput } from "../server/options"; import { resolveCapletsRemote, resolveHostedCloudRemote, resolveRemoteMode, type CapletsRemoteAuth, type CapletsRemoteEnv, + type CapletsRemoteInput, } from "../remote/options"; type CapletsMode = "auto" | "local" | "remote" | "cloud"; export type NativeCapletsMode = CapletsMode; -export type NativeRemoteCapletsOptions = { +export type NativeRemoteCapletsOptions = CapletsRemoteInput & { pollIntervalMs?: number; - fetch?: typeof fetch; cloud?: NativeCloudPresenceInput; }; @@ -28,11 +27,19 @@ export type NativeCloudPresenceInput = { export type NativeCapletsServiceResolutionInput = { mode?: NativeCapletsMode; - server?: CapletsServerInput; remote?: NativeRemoteCapletsOptions; }; -export type NativeCapletsEnv = CapletsServerEnv & CapletsRemoteEnv; +export type NativeCapletsEnv = CapletsRemoteEnv & + Partial< + Record< + | "CAPLETS_CLOUD_URL" + | "CAPLETS_CLOUD_TOKEN" + | "CAPLETS_CLOUD_WORKSPACE_ID" + | "CAPLETS_PROJECT_ROOT", + string + > + >; export type NativeRemoteAuthOptions = | { enabled: false; user: string } @@ -62,7 +69,7 @@ export function resolveNativeCapletsServiceOptions( const mode = resolveRemoteMode( { ...(input.mode ? { mode: input.mode } : {}), - ...(input.server?.url ? { remoteUrl: input.server.url } : {}), + ...(input.remote?.url ? { remoteUrl: input.remote.url } : {}), }, env, ); @@ -70,19 +77,15 @@ export function resolveNativeCapletsServiceOptions( return { mode: "local" }; } - const serverFetch = input.remote?.fetch ?? input.server?.fetch; - const serverInput = { - ...input.server, - ...(serverFetch ? { fetch: serverFetch } : {}), - }; + const remoteFetch = input.remote?.fetch; const server = mode.mode === "cloud" ? resolveNativeHostedCloudRemote( - input.server?.url ?? env.CAPLETS_REMOTE_URL ?? "", + input.remote?.url ?? env.CAPLETS_REMOTE_URL ?? "", optionalWorkspace(input, env).workspace, - serverFetch, + remoteFetch, ) - : resolveCapletsRemote(serverInput, env); + : resolveCapletsRemote(input.remote, env); const cloud = resolveNativeCloudPresence(input.remote?.cloud, env); return { @@ -119,6 +122,7 @@ function optionalWorkspace( ): { workspace?: string } { const workspace = input.remote?.cloud?.workspaceId ?? + input.remote?.workspace ?? env.CAPLETS_REMOTE_WORKSPACE ?? env.CAPLETS_CLOUD_WORKSPACE_ID; return workspace ? { workspace } : {}; diff --git a/packages/core/src/native/remote.ts b/packages/core/src/native/remote.ts index 2dccd265..e90ab12d 100644 --- a/packages/core/src/native/remote.ts +++ b/packages/core/src/native/remote.ts @@ -5,15 +5,30 @@ import { } from "../exposure/direct-names"; import { generatedToolInputJsonSchemaForCaplet, operations } from "../generated-tool-input-schema"; import type { AttachCodeModeCaplet, AttachManifest, AttachManifestExport } from "../attach/api"; +import { CodeModeJournalStore } from "../code-mode/journal"; import { runCodeMode } from "../code-mode/runner"; -import { codeModeRunInputJsonSchema, codeModeRunInputSchema } from "../code-mode/tool"; +import { CodeModeSessionManager } from "../code-mode/sessions"; +import { + generateCodeModeDeclarations, + generateCodeModeRunToolDescription, +} from "../code-mode/declarations"; +import { + codeModeRunInputJsonSchema, + codeModeRunInputSchema, + emptyCodeModeRunMeta, +} from "../code-mode/tool"; import type { ResolvedNativeCapletsServiceOptions } from "./options"; import type { NativeCapletsService, NativeCapletsToolsChangedListener, NativeCapletTool, } from "./service"; -import { nativeCapletToolName, nativeCodeModeToolId, nativeCodeModeToolName } from "./tools"; +import { + nativeCapletToolName, + nativeCodeModePromptGuidance, + nativeCodeModeToolId, + nativeCodeModeToolName, +} from "./tools"; export type RemoteCapletsTool = { name: string; @@ -169,6 +184,7 @@ export class RemoteNativeCapletsService implements NativeCapletsService { private client: RemoteCapletsClient; private unsubscribeRemote: () => void; private readonly pollTimer: ReturnType; + private readonly codeModeSessions = new CodeModeSessionManager(); private closed = false; private resetInFlight: Promise | undefined; @@ -188,7 +204,7 @@ export class RemoteNativeCapletsService implements NativeCapletsService { async execute(capletId: string, request: unknown): Promise { if (capletId === nativeCodeModeToolId) { - return await executeCodeModeRunRemote(this, request); + return await executeCodeModeRunRemote(this, request, this.codeModeSessions); } const remoteToolId = this.toolRoutes.get(capletId) ?? capletId; try { @@ -214,6 +230,17 @@ export class RemoteNativeCapletsService implements NativeCapletsService { } } + codeModeService(): NativeCapletsService { + return { + listTools: () => remoteCodeModeCallableNativeTools(this.listTools()), + execute: async (capletId, request) => await this.execute(capletId, request), + codeModeService: () => this.codeModeService(), + reload: async () => await this.reload(), + onToolsChanged: () => () => undefined, + close: async () => undefined, + }; + } + async reload(): Promise { if (this.closed) { return false; @@ -250,6 +277,7 @@ export class RemoteNativeCapletsService implements NativeCapletsService { } this.closed = true; clearInterval(this.pollTimer); + this.codeModeSessions.close(); this.unsubscribeRemote(); this.listeners.clear(); await this.client.close(); @@ -326,6 +354,7 @@ function remoteToolToNativeTool(tool: RemoteCapletsTool): NativeCapletTool { tool.sourceCapletId === undefined && !tool.codeModeRun ? operationNamesFromSchema(inputSchema) : undefined; + const description = tool.codeModeRun ? remoteCodeModeToolDescription(tool) : tool.description; return { caplet: capletId, ...(sourceCaplet && sourceCaplet !== capletId ? { sourceCaplet } : {}), @@ -333,12 +362,14 @@ function remoteToolToNativeTool(tool: RemoteCapletsTool): NativeCapletTool { toolName, title: tool.title ?? capletId, description: [ - tool.description ?? "Remote Caplets tool.", + description ?? "Remote Caplets tool.", "", `Native tool name: ${toolName}`, `Remote Caplet ID: ${capletId}`, ].join("\n"), - promptGuidance: [`Use ${toolName} through the remote Caplets service.`], + promptGuidance: tool.codeModeRun + ? nativeCodeModePromptGuidance() + : [`Use ${toolName} through the remote Caplets service.`], ...(tool.codeModeRun || capletId === nativeCodeModeToolId ? { codeModeRun: true } : {}), ...(tool.codeModeCaplets ? { @@ -357,6 +388,41 @@ function remoteToolToNativeTool(tool: RemoteCapletsTool): NativeCapletTool { }; } +function remoteCodeModeToolDescription(tool: RemoteCapletsTool): string | undefined { + if (!tool.codeModeCaplets || tool.codeModeCaplets.length === 0) return tool.description; + const declaration = generateCodeModeDeclarations({ + caplets: tool.codeModeCaplets.map((caplet) => ({ + id: caplet.capletId, + name: caplet.name, + description: caplet.description ?? "", + shadowing: caplet.shadowing, + })), + }); + return generateCodeModeRunToolDescription(declaration); +} + +function remoteCodeModeCallableNativeTools(tools: NativeCapletTool[]): NativeCapletTool[] { + const codeModeCaplets = tools.flatMap((tool) => tool.codeModeCaplets ?? []); + const hasExplicitCodeModeManifest = tools.some((tool) => tool.codeModeCaplets !== undefined); + if (codeModeCaplets.length === 0) { + return hasExplicitCodeModeManifest ? [] : tools.filter((tool) => tool.codeModeRun !== true); + } + const byId = new Map(tools.map((tool) => [tool.caplet, tool])); + return codeModeCaplets.map((caplet) => { + const tool = byId.get(caplet.id); + return { + caplet: caplet.id, + toolName: tool?.toolName ?? nativeCapletToolName(caplet.id), + title: caplet.name, + description: caplet.description, + ...(caplet.shadowing ? { shadowing: caplet.shadowing } : {}), + ...(caplet.useWhen ? { useWhen: caplet.useWhen } : {}), + ...(caplet.avoidWhen ? { avoidWhen: caplet.avoidWhen } : {}), + promptGuidance: tool?.promptGuidance ?? [], + }; + }); +} + function nativeToolRouteId(tool: RemoteCapletsTool): string { return tool.codeModeRun ? nativeCodeModeToolId : tool.name; } @@ -543,8 +609,9 @@ function primitiveInvokeInput( } function toolsFromManifest(manifest: AttachManifest): RemoteCapletsTool[] { - const codeModeMarker = attachCodeModeMarker(manifest); - const codeModeShadowing: "forbid" | "allow" = manifest.codeModeCaplets.some( + const codeModeCaplets = manifest.codeModeCaplets ?? []; + const codeModeMarker = attachCodeModeMarker(codeModeCaplets); + const codeModeShadowing: "forbid" | "allow" = codeModeCaplets.some( (entry) => entry.shadowing === "forbid", ) ? "forbid" @@ -572,7 +639,7 @@ function toolsFromManifest(manifest: AttachManifest): RemoteCapletsTool[] { ...codeModeMarker, })), ...primitiveToolsFromManifest(manifest, codeModeMarker), - ...(manifest.codeModeCaplets.length > 0 + ...(codeModeCaplets.length > 0 ? [ { name: nativeCodeModeToolId, @@ -580,7 +647,7 @@ function toolsFromManifest(manifest: AttachManifest): RemoteCapletsTool[] { title: "Code Mode", description: "Remote Caplets available to locally-run attached Code Mode.", codeModeRun: true, - codeModeCaplets: manifest.codeModeCaplets, + codeModeCaplets, shadowing: codeModeShadowing, inputSchema: codeModeRunInputJsonSchema(), }, @@ -590,9 +657,9 @@ function toolsFromManifest(manifest: AttachManifest): RemoteCapletsTool[] { } function attachCodeModeMarker( - manifest: AttachManifest, + codeModeCaplets: AttachCodeModeCaplet[] = [], ): Pick | Record { - return manifest.codeModeCaplets.length === 0 ? { codeModeCaplets: [] } : {}; + return codeModeCaplets.length === 0 ? { codeModeCaplets: [] } : {}; } function primitiveToolsFromManifest( @@ -724,7 +791,7 @@ function exportMapFor(manifest: AttachManifest): Map { const parsed = codeModeRunInputSchema.safeParse(request); if (!parsed.success) { @@ -815,21 +883,17 @@ async function executeCodeModeRunRemote( }, diagnostics: [], logs: { entries: [], truncated: false, stored: false }, - meta: { - runId: "", - traceId: "", - declarationHash: "", - durationMs: 0, - timeoutMs: 0, - maxTimeoutMs: 0, - }, + meta: emptyCodeModeRunMeta(), }; } return await runCodeMode({ code: parsed.data.code, service, ...(parsed.data.timeoutMs === undefined ? {} : { timeoutMs: parsed.data.timeoutMs }), + ...(parsed.data.sessionId === undefined ? {} : { sessionId: parsed.data.sessionId }), runtimeScope: process.env.CAPLETS_MODE?.trim() || "remote", + journalStore: new CodeModeJournalStore(), + ...(sessionManager === undefined ? {} : { sessionManager }), }); } @@ -844,7 +908,7 @@ function compatibleExport( ...manifest.resourceTemplates, ...manifest.prompts, ...manifest.completions, - ...manifest.codeModeCaplets, + ...(manifest.codeModeCaplets ?? []), ].find((entry) => entry.stableId === previous.stableId); if (!next) return undefined; if (next.kind !== previous.kind) return undefined; diff --git a/packages/core/src/native/service.ts b/packages/core/src/native/service.ts index 3e86cce4..aca1f52a 100644 --- a/packages/core/src/native/service.ts +++ b/packages/core/src/native/service.ts @@ -19,6 +19,7 @@ import { nativeCapletPromptGuidance, nativeCapletToolDescription, nativeCapletToolName, + nativeCodeModePromptGuidance, nativeCodeModeToolId, nativeCodeModeToolName, } from "./tools"; @@ -30,7 +31,13 @@ import { } from "../code-mode/declarations"; import type { DirectToolRegistration, ExposureSnapshot } from "../exposure/discovery"; import { runCodeMode } from "../code-mode/runner"; -import { codeModeRunInputJsonSchema, codeModeRunInputSchema } from "../code-mode/tool"; +import { CodeModeSessionManager } from "../code-mode/sessions"; +import { CodeModeJournalStore } from "../code-mode/journal"; +import { + codeModeRunInputJsonSchema, + codeModeRunInputSchema, + emptyCodeModeRunMeta, +} from "../code-mode/tool"; import type { CodeModeCallableCaplet } from "../code-mode/types"; import { loadLocalOverlayConfigWithSources, @@ -81,6 +88,7 @@ export type NativeCapletsToolsChangedListener = (tools: NativeCapletTool[]) => v export type NativeCapletsService = { listTools(): NativeCapletTool[]; execute(capletId: string, request: unknown): Promise; + codeModeService?(): NativeCapletsService; reload(): Promise; onToolsChanged(listener: NativeCapletsToolsChangedListener): () => void; close(): Promise; @@ -129,6 +137,7 @@ class DefaultNativeCapletsService implements NativeCapletsService { private readonly toolListeners = new Set(); private directToolRoutes = new Map(); private exposureSnapshot: ExposureSnapshot | undefined; + private readonly codeModeSessions = new CodeModeSessionManager(); private postReloadRefresh: Promise | undefined; private exposureRefreshGeneration = 0; @@ -179,7 +188,11 @@ class DefaultNativeCapletsService implements NativeCapletsService { async execute(capletId: string, request: unknown): Promise { if (capletId === nativeCodeModeToolId) { - return await executeCodeModeRunNative(this.codeModeDelegate(), request); + return await executeCodeModeRunNative( + this.codeModeDelegate(), + request, + this.codeModeSessions, + ); } const route = this.directToolRoutes.get(capletId); if (route) { @@ -198,6 +211,10 @@ class DefaultNativeCapletsService implements NativeCapletsService { return await this.engine.execute(capletId, request); } + codeModeService(): NativeCapletsService { + return this.codeModeDelegate(); + } + async reload(): Promise { const reloaded = await this.engine.reload(); await this.postReloadRefresh; @@ -213,6 +230,7 @@ class DefaultNativeCapletsService implements NativeCapletsService { async close(): Promise { this.unsubscribeEngineReload(); + this.codeModeSessions.close(); this.toolListeners.clear(); await this.engine.close(); } @@ -513,11 +531,7 @@ function codeModeRunNativeTool(capletTools: NativeCapletTool[]): NativeCapletToo ].join("\n"), codeModeRun: true, codeModeCaplets, - promptGuidance: [ - `Use ${nativeCodeModeToolName} to run Caplets Code Mode TypeScript with generated caplets. handles.`, - "Prefer Code Mode for multi-step Caplet discovery, tool calls, filtering, joins, and compact synthesis.", - "Return decision-ready JSON from Code Mode rather than raw bulky provider payloads.", - ], + promptGuidance: nativeCodeModePromptGuidance(), inputSchema: codeModeRunInputJsonSchema(), }; } @@ -552,6 +566,7 @@ function codeModeCallableNativeTools( async function executeCodeModeRunNative( service: NativeCapletsService, request: unknown, + sessionManager?: CodeModeSessionManager, ): Promise { const parsed = codeModeRunInputSchema.safeParse(request); if (!parsed.success) { @@ -564,21 +579,17 @@ async function executeCodeModeRunNative( }, diagnostics: [], logs: { entries: [], truncated: false, stored: false }, - meta: { - runId: "", - traceId: "", - declarationHash: "", - durationMs: 0, - timeoutMs: 0, - maxTimeoutMs: 0, - }, + meta: emptyCodeModeRunMeta(), }; } return await runCodeMode({ code: parsed.data.code, service, ...(parsed.data.timeoutMs === undefined ? {} : { timeoutMs: parsed.data.timeoutMs }), + ...(parsed.data.sessionId === undefined ? {} : { sessionId: parsed.data.sessionId }), runtimeScope: process.env.CAPLETS_MODE?.trim() || "local", + journalStore: new CodeModeJournalStore(), + ...(sessionManager === undefined ? {} : { sessionManager }), }); } @@ -643,13 +654,17 @@ class CloudNativeCapletsService implements NativeCapletsService { return await (this.delegate ?? this.local).execute(capletId, request); } + codeModeService(): NativeCapletsService { + return (this.delegate ?? this.local).codeModeService?.() ?? this.delegate ?? this.local; + } + async reload(): Promise { if (this.closed) return false; if (!this.delegate) { try { - const cloudFetch = this.options.remote?.fetch ?? this.options.server?.fetch; + const cloudFetch = this.options.remote?.fetch; const remoteUrl = - this.options.server?.url ?? this.baseRemote.url.toString().replace(/\/mcp$/u, ""); + this.options.remote?.url ?? this.baseRemote.url.toString().replace(/\/mcp$/u, ""); const selection = await resolveRemoteSelection( { mode: "cloud", @@ -738,6 +753,7 @@ class CompositeNativeCapletsService implements NativeCapletsService { private tools: NativeCapletTool[] = []; private closed = false; private batchingReload = false; + private readonly codeModeSessions = new CodeModeSessionManager(); constructor( private readonly remote: NativeCapletsService, @@ -764,7 +780,7 @@ class CompositeNativeCapletsService implements NativeCapletsService { async execute(capletId: string, request: unknown): Promise { if (capletId === nativeCodeModeToolId) { - return await executeCodeModeRunNative(this, request); + return await executeCodeModeRunNative(this, request, this.codeModeSessions); } if (this.localCanExecute(capletId)) { return await this.local.execute(capletId, request); @@ -772,6 +788,17 @@ class CompositeNativeCapletsService implements NativeCapletsService { return await this.remote.execute(capletId, request); } + codeModeService(): NativeCapletsService { + return { + listTools: () => codeModeCallableNativeTools(this.listTools(), { fallbackToVisible: false }), + execute: async (capletId, request) => await this.execute(capletId, request), + codeModeService: () => this.codeModeService(), + reload: async () => await this.reload(), + onToolsChanged: () => () => undefined, + close: async () => undefined, + }; + } + async reload(): Promise { if (this.closed) { return false; @@ -806,6 +833,7 @@ class CompositeNativeCapletsService implements NativeCapletsService { unsubscribe(); } this.listeners.clear(); + this.codeModeSessions.close(); await Promise.all([this.remote.close(), this.local.close(), this.presence?.close()]); } @@ -935,7 +963,7 @@ function createProjectBindingSessionManager( return undefined; } const projectRoot = cloud.projectRoot ?? findProjectRoot(); - const cloudFetch = options.remote?.fetch ?? options.server?.fetch; + const cloudFetch = options.remote?.fetch; const clientOptions = { baseUrl: cloud.url, accessToken: cloud.accessToken, diff --git a/packages/core/src/native/tools.ts b/packages/core/src/native/tools.ts index 6c8cccae..306aa622 100644 --- a/packages/core/src/native/tools.ts +++ b/packages/core/src/native/tools.ts @@ -19,6 +19,7 @@ export function nativeCapletsSystemGuidance(toolNames: string[]): string { tools, "", `${nativeCodeModeToolName} executes Caplets Code Mode: TypeScript with generated caplets. handles for multi-step discovery, tool calls, filtering, and compact synthesis in one native call.`, + ...nativeCodeModePromptGuidance(), "Flow: inspect when the domain is unfamiliar; use tools/search_tools for downstream names, arg hints, and callTemplate; call_tool directly from callTemplate/argsTemplate for simple calls; reserve describe_tool for complex schemas, nested args, fields, or uncertainty.", "Do not guess downstream tool names, resource URIs, prompt names, input args, output fields, or schemas. Do not infer input/output schemas from memory.", "Prefer list/read/search operations for triage and avoid broad provider searches that can return huge payloads or hit rate limits.", @@ -26,6 +27,17 @@ export function nativeCapletsSystemGuidance(toolNames: string[]): string { ].join("\n"); } +export function nativeCodeModePromptGuidance(): string[] { + return [ + `Use ${nativeCodeModeToolName} to run Caplets Code Mode TypeScript with generated caplets. handles.`, + "Prefer Code Mode for multi-step Caplet discovery, tool calls, filtering, joins, and compact synthesis.", + "For REPL reuse, omit sessionId to start fresh, then pass the returned meta.sessionId on later calls that should reuse live state.", + "Reused sessions preserve successful top-level var bindings, function declarations, and runtime state only while the live session remains available and compatible.", + "Unknown or unavailable sessionId values fail before code execution; use meta.recoveryRef with caplets.debug.readRecovery({ recoveryRef }) for audit and manual reconstruction, not automatic replay.", + "Return decision-ready JSON from Code Mode rather than raw bulky provider payloads.", + ]; +} + export function nativeCapletPromptGuidance(toolName: string, caplet: CapletConfig): string[] { const descriptorFirst = "Use tools/search_tools callTemplate/arg hints for simple calls; reserve describe_tool for exact schemas, nested args, fields, or uncertainty. call_tool.args must match inputSchema exactly. Do not guess tool names or schemas."; diff --git a/packages/core/src/remote/options.ts b/packages/core/src/remote/options.ts index ff7fab24..e1db9a79 100644 --- a/packages/core/src/remote/options.ts +++ b/packages/core/src/remote/options.ts @@ -9,8 +9,7 @@ export type CapletsRemoteEnv = Partial< | "CAPLETS_REMOTE_USER" | "CAPLETS_REMOTE_PASSWORD" | "CAPLETS_REMOTE_TOKEN" - | "CAPLETS_REMOTE_WORKSPACE" - | "CAPLETS_SERVER_URL", + | "CAPLETS_REMOTE_WORKSPACE", string > >; diff --git a/packages/core/src/serve/session.ts b/packages/core/src/serve/session.ts index 47ec54ff..ba21afe5 100644 --- a/packages/core/src/serve/session.ts +++ b/packages/core/src/serve/session.ts @@ -14,9 +14,15 @@ import { generateCodeModeDeclarations, generateCodeModeRunToolDescription, } from "../code-mode/declarations"; +import { CodeModeJournalStore } from "../code-mode/journal"; import { CodeModeLogStore } from "../code-mode/logs"; import { runCodeMode } from "../code-mode/runner"; -import { codeModeRunInputSchema, codeModeRunParamsSchema } from "../code-mode/tool"; +import { CodeModeSessionManager } from "../code-mode/sessions"; +import { + codeModeRunInputSchema, + codeModeRunParamsSchema, + emptyCodeModeRunMeta, +} from "../code-mode/tool"; import type { CapletsEngine } from "../engine"; import type { CallableCaplet, @@ -55,6 +61,7 @@ export class CapletsMcpSession { private readonly resources = new Map(); private readonly prompts = new Map(); private codeModeTool: RegisteredTool | undefined; + private readonly codeModeSessions = new CodeModeSessionManager(); private readonly unsubscribeReload: () => void; private closed = false; @@ -98,6 +105,7 @@ export class CapletsMcpSession { if (this.closed) return; this.closed = true; this.unsubscribeReload(); + this.codeModeSessions.close(); this.clearRegistrations(); await this.server.close(); } @@ -222,7 +230,11 @@ export class CapletsMcpSession { code: parsed.data.code, service: new EngineNativeCapletsService(this.engine), ...(parsed.data.timeoutMs === undefined ? {} : { timeoutMs: parsed.data.timeoutMs }), + ...(parsed.data.sessionId === undefined ? {} : { sessionId: parsed.data.sessionId }), logStore: new CodeModeLogStore(), + journalStore: new CodeModeJournalStore(), + sessionManager: this.codeModeSessions, + runtimeScope: "mcp", }) : { ok: false as const, @@ -233,14 +245,7 @@ export class CapletsMcpSession { }, diagnostics: [], logs: { entries: [], truncated: false, stored: false }, - meta: { - runId: "", - traceId: "", - declarationHash: "", - durationMs: 0, - timeoutMs: 0, - maxTimeoutMs: 0, - }, + meta: emptyCodeModeRunMeta(), }; return { content: [{ type: "text" as const, text: JSON.stringify(envelope, null, 2) }], diff --git a/packages/core/test/cli-remote.test.ts b/packages/core/test/cli-remote.test.ts index 665dfb85..a131917d 100644 --- a/packages/core/test/cli-remote.test.ts +++ b/packages/core/test/cli-remote.test.ts @@ -30,7 +30,7 @@ describe("remote CLI routing", () => { await runCli(["__complete", "--shell", "bash", "--", "inspect", ""], { env: { CAPLETS_MODE: "remote", - CAPLETS_SERVER_URL: "http://127.0.0.1:5387/caplets", + CAPLETS_REMOTE_URL: "http://127.0.0.1:5387/caplets", CAPLETS_CONFIG: join(dirname(context.configPath), "missing-config.json"), CAPLETS_PROJECT_CONFIG: context.projectConfigPath, }, @@ -156,7 +156,7 @@ describe("remote CLI routing", () => { await runCli(["__complete", "--shell", "bash", "--", "inspect", ""], { env: { CAPLETS_MODE: "remote", - CAPLETS_SERVER_URL: "http://127.0.0.1:5387/caplets", + CAPLETS_REMOTE_URL: "http://127.0.0.1:5387/caplets", }, fetch, writeOut: (value) => out.push(value), @@ -212,7 +212,7 @@ describe("remote CLI routing", () => { await runCli(["list", "--json"], { env: { CAPLETS_MODE: "remote", - CAPLETS_SERVER_URL: "http://127.0.0.1:5387/caplets", + CAPLETS_REMOTE_URL: "http://127.0.0.1:5387/caplets", CAPLETS_CONFIG: join(dirname(context.configPath), "missing-config.json"), CAPLETS_PROJECT_CONFIG: context.projectConfigPath, }, @@ -412,7 +412,7 @@ describe("remote CLI routing", () => { { env: { CAPLETS_MODE: "remote", - CAPLETS_SERVER_URL: "http://127.0.0.1:5387/caplets", + CAPLETS_REMOTE_URL: "http://127.0.0.1:5387/caplets", CAPLETS_CONFIG: join(dirname(context.configPath), "missing-config.json"), CAPLETS_PROJECT_CONFIG: context.projectConfigPath, }, @@ -601,7 +601,7 @@ describe("remote CLI routing", () => { }); const io = { - env: { CAPLETS_MODE: "remote", CAPLETS_SERVER_URL: "http://127.0.0.1:5387/caplets" }, + env: { CAPLETS_MODE: "remote", CAPLETS_REMOTE_URL: "http://127.0.0.1:5387/caplets" }, fetch, writeOut: (value: string) => out.push(value), }; @@ -642,7 +642,7 @@ describe("remote CLI routing", () => { await runCli(["config", "path"], { env: { CAPLETS_MODE: "remote", - CAPLETS_SERVER_URL: "http://127.0.0.1:5387/caplets", + CAPLETS_REMOTE_URL: "http://127.0.0.1:5387/caplets", CAPLETS_CONFIG: "/tmp/caplets-local-config.json", }, fetch, @@ -660,7 +660,7 @@ describe("remote CLI routing", () => { await runCli(["config", "paths", "--json"], { env: { CAPLETS_MODE: "remote", - CAPLETS_SERVER_URL: "http://127.0.0.1:5387/caplets", + CAPLETS_REMOTE_URL: "http://127.0.0.1:5387/caplets", CAPLETS_CONFIG: "/tmp/caplets-local-config.json", }, fetch, @@ -760,7 +760,7 @@ describe("remote CLI routing", () => { { env: { CAPLETS_MODE: "remote", - CAPLETS_SERVER_URL: "http://127.0.0.1:5387", + CAPLETS_REMOTE_URL: "http://127.0.0.1:5387", }, fetch: fetchMock as typeof fetch, writeOut: (value) => out.push(value), @@ -800,7 +800,7 @@ describe("remote CLI routing", () => { { env: { CAPLETS_MODE: "remote", - CAPLETS_SERVER_URL: "http://127.0.0.1:5387/caplets", + CAPLETS_REMOTE_URL: "http://127.0.0.1:5387/caplets", CAPLETS_CONFIG: local.configPath, }, fetch: async (input, init) => remote.app.fetch(new Request(input, init)), @@ -1029,7 +1029,7 @@ describe("remote CLI routing", () => { await runCli(["install", "spiritledsoftware/caplets", "github", "--remote"], { env: { CAPLETS_MODE: "remote", - CAPLETS_SERVER_URL: "http://127.0.0.1:5387", + CAPLETS_REMOTE_URL: "http://127.0.0.1:5387", }, fetch: fetchMock as typeof fetch, writeOut: (value) => out.push(value), @@ -1116,7 +1116,7 @@ describe("remote CLI routing", () => { await runCli(["init", "--force", "--remote"], { env: { CAPLETS_MODE: "remote", - CAPLETS_SERVER_URL: "http://127.0.0.1:5387", + CAPLETS_REMOTE_URL: "http://127.0.0.1:5387", }, fetch: fetchMock as typeof fetch, writeOut: (value) => out.push(value), @@ -1191,7 +1191,7 @@ describe("remote CLI routing", () => { const io = { env: { CAPLETS_MODE: "remote", - CAPLETS_SERVER_URL: "http://127.0.0.1:5387", + CAPLETS_REMOTE_URL: "http://127.0.0.1:5387", }, fetch: fetchMock as typeof fetch, writeOut: (value: string) => out.push(value), @@ -1298,7 +1298,7 @@ describe("remote CLI routing", () => { const io = { env: { CAPLETS_MODE: "remote", - CAPLETS_SERVER_URL: "http://127.0.0.1:5387", + CAPLETS_REMOTE_URL: "http://127.0.0.1:5387", }, fetch: fetchMock as typeof fetch, writeOut: (value: string) => out.push(value), @@ -1341,7 +1341,7 @@ describe("remote CLI routing", () => { await runCli(["auth", "login", "remote"], { env: { CAPLETS_MODE: "remote", - CAPLETS_SERVER_URL: "http://127.0.0.1:5387", + CAPLETS_REMOTE_URL: "http://127.0.0.1:5387", }, fetch: fetchMock as typeof fetch, writeOut: (value) => out.push(value), @@ -1366,7 +1366,7 @@ describe("remote CLI routing", () => { await runCli(["auth", "login", "remote", "--no-open"], { env: { CAPLETS_MODE: "remote", - CAPLETS_SERVER_URL: "http://127.0.0.1:5387", + CAPLETS_REMOTE_URL: "http://127.0.0.1:5387", }, fetch: fetchMock as typeof fetch, writeOut: (value) => out.push(value), @@ -1399,7 +1399,7 @@ async function runRemoteAdd(args: string[]): Promise { await runCli(["add", ...args], { env: { CAPLETS_MODE: "remote", - CAPLETS_SERVER_URL: "http://127.0.0.1:5387", + CAPLETS_REMOTE_URL: "http://127.0.0.1:5387", }, fetch: fetchMock as typeof fetch, writeOut: () => {}, @@ -1454,7 +1454,7 @@ function remoteEnv(context: { }): Record { return { CAPLETS_MODE: "remote", - CAPLETS_SERVER_URL: "http://127.0.0.1:5387/caplets", + CAPLETS_REMOTE_URL: "http://127.0.0.1:5387/caplets", CAPLETS_CONFIG: context.configPath, CAPLETS_PROJECT_CONFIG: context.projectConfigPath, }; diff --git a/packages/core/test/cli.test.ts b/packages/core/test/cli.test.ts index 20189c3d..767d73ed 100644 --- a/packages/core/test/cli.test.ts +++ b/packages/core/test/cli.test.ts @@ -742,7 +742,7 @@ describe("cli init", () => { { env: { CAPLETS_MODE: "remote", - CAPLETS_SERVER_URL: "http://127.0.0.1:5387", + CAPLETS_REMOTE_URL: "http://127.0.0.1:5387", CAPLETS_CONFIG: join(dir, "missing-user-config.json"), CAPLETS_PROJECT_CONFIG: join(dir, "project", ".caplets", "config.json"), }, @@ -2830,8 +2830,8 @@ describe("cli setup", () => { }, ], nextSteps: [ - "Run OpenCode with CAPLETS_MODE=remote and CAPLETS_SERVER_URL=https://caplets.example.test/caplets.", - "Keep CAPLETS_SERVER_PASSWORD in your shell or secret manager.", + "Run OpenCode with CAPLETS_MODE=remote and CAPLETS_REMOTE_URL=https://caplets.example.test/caplets.", + "Keep CAPLETS_REMOTE_TOKEN or CAPLETS_REMOTE_PASSWORD in your shell or secret manager.", ], }); }); diff --git a/packages/core/test/code-mode-api.test.ts b/packages/core/test/code-mode-api.test.ts index 99d9e657..c84928b0 100644 --- a/packages/core/test/code-mode-api.test.ts +++ b/packages/core/test/code-mode-api.test.ts @@ -556,7 +556,7 @@ describe("Code Mode Caplets API", () => { expect(JSON.stringify(result)).not.toContain('"_meta"'); }); - it("adds debug.readLogs without hiding a debug caplet handle", () => { + it("adds debug helpers without hiding a debug caplet handle", () => { const native = service([ { caplet: "debug", @@ -571,11 +571,18 @@ describe("Code Mode Caplets API", () => { readLogs: vi.fn(async () => ({ entries: [], })), + readRecovery: vi.fn(async () => ({ + entries: [], + })), }); - const debug = api.debug as CodeModeCapletHandle & { readLogs: unknown }; + const debug = api.debug as CodeModeCapletHandle & { + readLogs: unknown; + readRecovery: unknown; + }; expect(debug.id).toBe("debug"); expect(debug.readLogs).toBeTypeOf("function"); + expect(debug.readRecovery).toBeTypeOf("function"); expect(debug.callTool).toBeTypeOf("function"); }); }); diff --git a/packages/core/test/code-mode-cli.test.ts b/packages/core/test/code-mode-cli.test.ts index af459851..88726d4f 100644 --- a/packages/core/test/code-mode-cli.test.ts +++ b/packages/core/test/code-mode-cli.test.ts @@ -45,6 +45,75 @@ describe("Code Mode CLI", () => { expect(JSON.parse(out.join(""))).toMatchObject({ ok: true, value: { ok: true }, + meta: { + sessionId: null, + sessionStatus: null, + recoveryRef: null, + }, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects session ids for one-shot code-mode runs before executing code", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-code-mode-cli-")); + const out: string[] = []; + let exitCode = 0; + try { + process.env.CAPLETS_CONFIG = writeConfig(dir, {}); + + await runCli( + ["code-mode", "throw new Error('executed');", "--session-id", "session-123", "--json"], + { + writeOut: (value) => out.push(value), + setExitCode: (code) => { + exitCode = code; + }, + }, + ); + + expect(exitCode).toBe(1); + expect(JSON.parse(out.join(""))).toMatchObject({ + ok: false, + error: { + code: "SESSION_NOT_FOUND", + message: expect.stringContaining("do not support --session-id"), + }, + meta: { + sessionId: null, + sessionStatus: null, + recoveryRef: null, + }, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("does not expose progressive-only Caplets to one-shot code-mode runs", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-code-mode-cli-")); + const out: string[] = []; + try { + process.env.CAPLETS_CONFIG = writeConfig(dir, { + options: { exposure: "progressive" }, + mcpServers: { + alpha: { + name: "Alpha", + description: "Progressive-only operations.", + command: "node", + exposure: "progressive", + }, + }, + }); + + await runCli(["code-mode", "return Object.keys(caplets).sort();", "--json"], { + writeOut: (value) => out.push(value), + }); + + expect(JSON.parse(out.join(""))).toMatchObject({ + ok: true, + value: ["debug"], }); } finally { rmSync(dir, { recursive: true, force: true }); @@ -148,6 +217,63 @@ describe("Code Mode CLI", () => { rmSync(dir, { recursive: true, force: true }); } }); + + it("prints repl help with session and recovery option scaffolding", async () => { + const out: string[] = []; + + await runCli(["code-mode", "repl", "--help"], { + writeOut: (value) => out.push(value), + }); + + expect(out.join("")).toContain("--session-id "); + expect(out.join("")).toContain("--recover "); + }); + + it("prints unsupported repl scaffolding as a JSON envelope", async () => { + const out: string[] = []; + let exitCode = 0; + + await runCli(["code-mode", "repl", "--json"], { + writeOut: (value) => out.push(value), + setExitCode: (code) => { + exitCode = code; + }, + }); + + expect(exitCode).toBe(1); + expect(JSON.parse(out.join(""))).toMatchObject({ + ok: false, + error: { code: "UNSUPPORTED_OPERATION" }, + meta: { + sessionId: null, + sessionStatus: null, + recoveryRef: null, + }, + }); + }); + + it("routes recovery-only code-mode calls to unsupported repl scaffolding", async () => { + const out: string[] = []; + let exitCode = 0; + + await runCli(["code-mode", "--recover", "recovery-123", "--json"], { + writeOut: (value) => out.push(value), + setExitCode: (code) => { + exitCode = code; + }, + }); + + expect(exitCode).toBe(1); + expect(JSON.parse(out.join(""))).toMatchObject({ + ok: false, + error: { code: "UNSUPPORTED_OPERATION" }, + meta: { + sessionId: null, + sessionStatus: null, + recoveryRef: null, + }, + }); + }); }); function writeConfig(dir: string, config: Record): string { diff --git a/packages/core/test/code-mode-declarations.test.ts b/packages/core/test/code-mode-declarations.test.ts index 58ebd939..6072bee5 100644 --- a/packages/core/test/code-mode-declarations.test.ts +++ b/packages/core/test/code-mode-declarations.test.ts @@ -61,6 +61,11 @@ describe("generateCodeModeDeclarations", () => { expect(declaration).not.toContain("fieldSelection"); expect(declaration).toContain("resources(input?:PageInput):Promise>"); expect(declaration).toContain("readLogs(input:ReadLogsInput):Promise"); + expect(declaration).toContain('type CodeModeSessionStatus="created"|"reused"'); + expect(declaration).toContain("sessionId?:string"); + expect(declaration).toContain("sessionStatus?:CodeModeSessionStatus"); + expect(declaration).toContain("recoveryRef?:string"); + expect(declaration).not.toContain("recoveryCommand?:string"); expect(declaration).not.toContain("\n\n"); expect(declaration).not.toContain(" = "); }); @@ -111,6 +116,12 @@ describe("generateCodeModeDeclarations", () => { expect(description).toContain("Never invent tool names, resource URIs, prompt names"); expect(description).toContain("use requiredArgs/acceptedArgs for simple calls"); expect(description).toContain("exact callSignature/inputSchema/inputTypeScript"); + expect(description).toContain("omit `sessionId` to start a fresh reusable Code Mode session"); + expect(description).toContain("keep `meta.sessionId`"); + expect(description).toContain("successful top-level `var` bindings, function declarations"); + expect(description).toContain("fails before executing your code"); + expect(description).toContain("Use `meta.recoveryRef` with `caplets.debug.readRecovery"); + expect(description).toContain("do not automatically replay recovery history"); expect(description).toContain("Generated declaration hints:"); expect(description).toContain(declaration); expect(description).not.toContain("Do not split discovery and execution"); diff --git a/packages/core/test/code-mode-journal.test.ts b/packages/core/test/code-mode-journal.test.ts new file mode 100644 index 00000000..bd90ba79 --- /dev/null +++ b/packages/core/test/code-mode-journal.test.ts @@ -0,0 +1,292 @@ +import { + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { CodeModeJournalStore, classifyCodeModeRecovery } from "../src/code-mode/journal"; + +describe("Code Mode journal", () => { + it("stores redacted entries without raw capability tokens and reads by recovery ref", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-code-mode-journal-")); + try { + const store = new CodeModeJournalStore({ + stateDir: dir, + secret: "test-secret", + now: () => new Date("2026-06-17T12:00:00.000Z"), + }); + const sessionId = "019ed8b0-a705-7bd1-bf54-62f0d30c9e94"; + + const stored = await store.store({ + sessionId, + code: `const token = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKL0123456789';\nconst session = '${sessionId}';\nreturn token;`, + declarationHash: "hash-1", + outcome: { ok: true }, + diagnostics: [], + recoveryClassification: "setup_like", + logRef: "a".repeat(48), + }); + + expect(stored.recoveryRef).toMatch(/^[a-f0-9]{48}$/u); + const raw = readFileSync( + join(dir, "code-mode", "journal", `${stored.journalKey}.json`), + "utf8", + ); + expect(raw).not.toContain(sessionId); + expect(raw).not.toContain(stored.recoveryRef); + expect(raw).not.toContain("a".repeat(48)); + expect(raw).not.toContain("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKL0123456789"); + expect(raw).toContain("[REDACTED:credential]"); + expect(raw).toContain("[REDACTED:capability]"); + + const read = await store.readRecovery({ recoveryRef: stored.recoveryRef }); + + expect(read.entries).toEqual([ + expect.objectContaining({ + code: expect.stringContaining("[REDACTED:credential]"), + declarationHash: "hash-1", + outcome: { ok: true }, + recoveryClassification: "setup_like", + logsStored: true, + }), + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("looks up retained history by session id without revealing recovery refs", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-code-mode-journal-")); + try { + const first = new CodeModeJournalStore({ + stateDir: dir, + secret: "test-secret", + now: () => new Date("2026-06-17T12:00:00.000Z"), + }); + const stored = await first.store({ + sessionId: "session-retained", + code: "function helper() { return 1; }", + declarationHash: "hash-1", + outcome: { ok: true }, + diagnostics: [], + recoveryClassification: "setup_like", + }); + const second = new CodeModeJournalStore({ + stateDir: dir, + secret: "test-secret", + now: () => new Date("2026-06-17T12:00:01.000Z"), + }); + + expect(readdirSync(join(dir, "code-mode", "journal", "recovery-index"))).toHaveLength(1); + expect(readdirSync(join(dir, "code-mode", "journal", "session-index"))).toHaveLength(1); + await expect(second.lookupSession("session-retained")).resolves.toEqual({ + expiresAt: stored.expiresAt, + recoveryRef: stored.recoveryRef, + }); + await expect(second.readRecovery({ recoveryRef: stored.recoveryRef })).resolves.toMatchObject( + { + entries: [expect.objectContaining({ recoveryClassification: "setup_like" })], + }, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("keeps earlier recovery refs valid across fresh journal store instances", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-code-mode-journal-")); + try { + const firstStore = new CodeModeJournalStore({ + stateDir: dir, + secret: "test-secret", + now: () => new Date("2026-06-17T12:00:00.000Z"), + }); + const first = await firstStore.store({ + sessionId: "session-stable-ref", + code: "var first = 1;", + declarationHash: "hash-1", + outcome: { ok: true }, + diagnostics: [], + recoveryClassification: "setup_like", + }); + const secondStore = new CodeModeJournalStore({ + stateDir: dir, + secret: "test-secret", + now: () => new Date("2026-06-17T12:00:01.000Z"), + }); + const second = await secondStore.store({ + sessionId: "session-stable-ref", + code: "var second = 2;", + declarationHash: "hash-1", + outcome: { ok: true }, + diagnostics: [], + recoveryClassification: "setup_like", + }); + + expect(second.recoveryRef).toBe(first.recoveryRef); + await expect( + secondStore.readRecovery({ recoveryRef: first.recoveryRef }), + ).resolves.toMatchObject({ + entries: [expect.any(Object), expect.any(Object)], + }); + await expect( + secondStore.readRecovery({ recoveryRef: second.recoveryRef }), + ).resolves.toMatchObject({ + entries: [expect.any(Object), expect.any(Object)], + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("trims old entries, expires files, and rejects invalid recovery refs", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-code-mode-journal-")); + try { + let now = new Date("2026-06-17T12:00:00.000Z"); + const store = new CodeModeJournalStore({ + stateDir: dir, + secret: "test-secret", + now: () => now, + retentionMs: 10, + maxEntries: 2, + maxCodeBytes: 8, + }); + + const first = await store.store({ + sessionId: "session-capped", + code: "const alpha = 1;", + declarationHash: "hash-1", + outcome: { ok: true }, + diagnostics: [], + recoveryClassification: "setup_like", + }); + await store.store({ + sessionId: "session-capped", + code: "const beta = 2;", + declarationHash: "hash-1", + outcome: { ok: true }, + diagnostics: [], + recoveryClassification: "setup_like", + }); + await store.store({ + sessionId: "session-capped", + code: "const gamma = 3;", + declarationHash: "hash-1", + outcome: { ok: true }, + diagnostics: [], + recoveryClassification: "setup_like", + }); + + const retained = await store.readRecovery({ recoveryRef: first.recoveryRef }); + expect(retained.entries).toHaveLength(2); + expect(retained.entries[0]?.code).toBe("const be"); + await expect(store.readRecovery({ recoveryRef: "not-a-ref" })).resolves.toEqual({ + entries: [], + }); + await expect( + store.readRecovery({ recoveryRef: first.recoveryRef, limit: 0 }), + ).resolves.toEqual({ + entries: [], + }); + + now = new Date("2026-06-17T12:00:00.011Z"); + await expect(store.readRecovery({ recoveryRef: first.recoveryRef })).resolves.toEqual({ + entries: [], + }); + await expect(store.lookupSession("session-capped")).resolves.toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("uses owner-only journal files and rejects symlinked storage paths", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-code-mode-journal-")); + try { + const store = new CodeModeJournalStore({ + stateDir: dir, + secret: "test-secret", + now: () => new Date("2026-06-17T12:00:00.000Z"), + }); + const stored = await store.store({ + sessionId: "session-mode", + code: "var x = 1;", + declarationHash: "hash-1", + outcome: { ok: true }, + diagnostics: [], + recoveryClassification: "setup_like", + }); + + expect(lstatSync(join(dir, "code-mode", "journal")).mode & 0o777).toBe(0o700); + expect( + lstatSync(join(dir, "code-mode", "journal", `${stored.journalKey}.json`)).mode & 0o777, + ).toBe(0o600); + + const symlinkRoot = mkdtempSync(join(tmpdir(), "caplets-code-mode-journal-link-")); + mkdirSync(join(symlinkRoot, "code-mode")); + symlinkSync(join(dir, "code-mode", "journal"), join(symlinkRoot, "code-mode", "journal")); + const symlinked = new CodeModeJournalStore({ + stateDir: symlinkRoot, + secret: "test-secret", + }); + + await expect( + symlinked.store({ + sessionId: "session-link", + code: "var x = 1;", + declarationHash: "hash-1", + outcome: { ok: true }, + diagnostics: [], + recoveryClassification: "setup_like", + }), + ).rejects.toThrow("Code Mode journal path must not contain symlinks"); + rmSync(symlinkRoot, { recursive: true, force: true }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects a symlinked intermediate code-mode parent", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-code-mode-journal-parent-link-")); + const outside = mkdtempSync(join(tmpdir(), "caplets-code-mode-journal-outside-")); + try { + symlinkSync(outside, join(dir, "code-mode")); + const store = new CodeModeJournalStore({ + stateDir: dir, + secret: "test-secret", + }); + + await expect( + store.store({ + sessionId: "session-link", + code: "var x = 1;", + declarationHash: "hash-1", + outcome: { ok: true }, + diagnostics: [], + recoveryClassification: "setup_like", + }), + ).rejects.toThrow("Code Mode journal path must not contain symlinks"); + } finally { + rmSync(dir, { recursive: true, force: true }); + rmSync(outside, { recursive: true, force: true }); + } + }); + + it("classifies Caplet execution as side-effecting and helper setup as setup-like", () => { + expect( + classifyCodeModeRecovery({ code: "function helper() { return 1; }", invokedCaplet: false }), + ).toBe("setup_like"); + expect(classifyCodeModeRecovery({ code: "return 1;", invokedCaplet: false })).toBe("unknown"); + expect( + classifyCodeModeRecovery({ + code: 'return await caplets.github.callTool("create", {});', + invokedCaplet: true, + }), + ).toBe("side_effecting"); + }); +}); diff --git a/packages/core/test/code-mode-mcp.test.ts b/packages/core/test/code-mode-mcp.test.ts index cff8e95d..921838aa 100644 --- a/packages/core/test/code-mode-mcp.test.ts +++ b/packages/core/test/code-mode-mcp.test.ts @@ -30,6 +30,17 @@ describe("Code Mode MCP tool", () => { expect(server.registered.get("github")).toBeDefined(); expect(server.registered.get("code_mode")).toBeDefined(); expect(server.registered.get("run")).toBeUndefined(); + const codeModeInputSchema = server.definitions.get("code_mode")?.inputSchema as Record< + string, + { description?: string } + >; + expect(codeModeInputSchema).toHaveProperty("sessionId"); + expect(codeModeInputSchema.sessionId?.description).toContain( + "Omit to create a fresh reusable session", + ); + expect(codeModeInputSchema.sessionId?.description).toContain( + "Unknown or unavailable session IDs fail before code execution", + ); expect(server.definitions.get("code_mode")?.description).toContain("caplets."); expect(server.definitions.get("code_mode")?.description).toContain( "Prefer a compact one-pass script for most tasks", @@ -62,6 +73,16 @@ describe("Code Mode MCP tool", () => { expect(server.definitions.get("code_mode")?.description).toContain( "exact callSignature/inputSchema/inputTypeScript", ); + expect(server.definitions.get("code_mode")?.description).toContain( + "omit `sessionId` to start a fresh reusable Code Mode session", + ); + expect(server.definitions.get("code_mode")?.description).toContain("`meta.sessionId`"); + expect(server.definitions.get("code_mode")?.description).toContain( + "fails before executing your code", + ); + expect(server.definitions.get("code_mode")?.description).toContain( + "do not automatically replay recovery history", + ); expect(server.definitions.get("code_mode")?.description).toContain( "list broad candidate records", ); @@ -111,12 +132,97 @@ describe("Code Mode MCP tool", () => { expect(result?.structuredContent).toMatchObject({ ok: true, value: { ok: true }, + meta: { + sessionId: expect.any(String), + sessionStatus: "created", + recoveryRef: expect.stringMatching(/^[a-f0-9]{48}$/u), + }, }); expect(result?.content[0]).toMatchObject({ type: "text" }); await session.close(); await engine.close(); }); + + it("reuses issued session ids and rejects unknown session ids", async () => { + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + github: { name: "GitHub", description: "GitHub repo operations.", command: "node" }, + }, + }); + dirs.push(dir); + const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false }); + const server = mockServer(); + const session = new CapletsMcpSession(engine, { server }); + const callback = server.callbacks.get("code_mode"); + + const first = await callback?.({ code: "var counter = 1;\nreturn counter;" }); + const sessionId = first?.structuredContent?.meta.sessionId as string; + const reused = await callback?.({ + code: "counter += 1;\nreturn counter;", + sessionId, + }); + const missing = await callback?.({ code: "return { ok: true };", sessionId: "session-123" }); + + expect(reused?.structuredContent).toMatchObject({ + ok: true, + value: 2, + meta: { + sessionId, + sessionStatus: "reused", + recoveryRef: null, + }, + }); + expect(missing?.structuredContent).toMatchObject({ + ok: false, + error: { code: "SESSION_NOT_FOUND" }, + meta: { sessionId: "session-123", sessionStatus: null }, + }); + + await session.close(); + await engine.close(); + }); + + it("returns invalid-request envelopes with session metadata scaffolding", async () => { + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + github: { name: "GitHub", description: "GitHub repo operations.", command: "node" }, + }, + }); + dirs.push(dir); + const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false }); + const server = mockServer(); + const session = new CapletsMcpSession(engine, { server }); + const callback = server.callbacks.get("code_mode"); + + const result = await callback?.({ timeoutMs: 1000 }); + const meta = result?.structuredContent?.meta as Record; + + expect(result?.structuredContent).toMatchObject({ + ok: false, + error: { + code: "REQUEST_INVALID", + message: "Code Mode run input is invalid.", + }, + meta: { + sessionId: null, + sessionStatus: null, + recoveryRef: null, + }, + }); + expect(Object.prototype.hasOwnProperty.call(meta, "sessionId")).toBe(true); + expect(Object.prototype.hasOwnProperty.call(meta, "sessionStatus")).toBe(true); + expect(Object.prototype.hasOwnProperty.call(meta, "recoveryRef")).toBe(true); + expect(Object.prototype.hasOwnProperty.call(meta, "recoveryCommand")).toBe(false); + expect(JSON.parse(result?.content[0].text ?? "{}").meta).toMatchObject({ + sessionId: null, + sessionStatus: null, + recoveryRef: null, + }); + + await session.close(); + await engine.close(); + }); }); function tempConfig(config: unknown): { @@ -144,26 +250,28 @@ function progressiveTestConfig(config: unknown): unknown { function mockServer() { const registered = new Map(); - const definitions = new Map(); + const definitions = new Map(); const callbacks = new Map Promise>(); return { registered, definitions, callbacks, - registerTool: vi.fn((name: string, definition: { description?: string }, callback) => { - const tool = { - update: vi.fn(), - remove: vi.fn(() => registered.delete(name)), - enable: vi.fn(), - disable: vi.fn(), - enabled: true, - handler: vi.fn(), - } as unknown as RegisteredTool; - registered.set(name, tool); - definitions.set(name, definition); - callbacks.set(name, callback); - return tool; - }), + registerTool: vi.fn( + (name: string, definition: { description?: string; inputSchema?: unknown }, callback) => { + const tool = { + update: vi.fn(), + remove: vi.fn(() => registered.delete(name)), + enable: vi.fn(), + disable: vi.fn(), + enabled: true, + handler: vi.fn(), + } as unknown as RegisteredTool; + registered.set(name, tool); + definitions.set(name, definition); + callbacks.set(name, callback); + return tool; + }, + ), connect: vi.fn(async () => {}), close: vi.fn(async () => {}), }; diff --git a/packages/core/test/code-mode-public-api.test.ts b/packages/core/test/code-mode-public-api.test.ts index 5106caef..752600f0 100644 --- a/packages/core/test/code-mode-public-api.test.ts +++ b/packages/core/test/code-mode-public-api.test.ts @@ -17,9 +17,32 @@ describe("@caplets/core/code-mode public API", () => { it("imports as a pure API entrypoint without runtime-only exports", () => { const entrypoint = readFileSync(resolve(codeModeSourceRoot, "index.ts"), "utf8"); - expect(entrypoint).not.toContain("codeModeRunInputSchema"); - expect(entrypoint).not.toContain("runCodeMode"); - expect(entrypoint).not.toContain("QuickJsCodeModeSandbox"); + const forbiddenRuntimeExports = [ + "codeModeRunInputSchema", + "runCodeMode", + "RunCodeModeInput", + "QuickJsCodeModeSandbox", + "CodeModeSandbox", + "CodeModeReplSession", + "CodeModeSessionManager", + "CodeModeSessionRunInput", + "CodeModeSessionRunResult", + "CodeModeJournalStore", + "CodeModeJournalEntry", + "StoreCodeModeJournalEntryInput", + "CODE_MODE_SESSION_COMPATIBILITY_VERSION", + "DEFAULT_CODE_MODE_SESSION_TTL_MS", + "DEFAULT_CODE_MODE_SESSION_LIMIT", + ]; + for (const forbiddenExport of forbiddenRuntimeExports) { + expect(entrypoint).not.toContain(forbiddenExport); + } + + expect(entrypointModules(resolve(codeModeSourceRoot, "index.ts"))).toEqual([ + "./declarations", + "./static-analysis", + "./types", + ]); expect(transitiveValueImports(resolve(codeModeSourceRoot, "index.ts"))).toEqual([ "static-analysis.ts imports @babel/parser", ]); @@ -44,6 +67,17 @@ describe("@caplets/core/code-mode public API", () => { 'const h=caplets["caplet-id"]', ); }); + + it("exports public session and recovery declaration types without runtime stores", () => { + const entrypoint = readFileSync(resolve(codeModeSourceRoot, "index.ts"), "utf8"); + + expect(entrypoint).toContain("CodeModeSessionStatus"); + expect(entrypoint).toContain("ReadCodeModeRecoveryInput"); + expect(entrypoint).toContain("ReadCodeModeRecoveryResult"); + expect(entrypoint).toContain("CodeModeRecoveryEntry"); + expect(entrypoint).not.toContain("CodeModeJournalStore"); + expect(entrypoint).not.toContain("CodeModeSessionManager"); + }); }); function transitiveValueImports(entrypoint: string): string[] { @@ -102,6 +136,31 @@ function valueSpecifiers(source: string): string[] { return specifiers; } +function entrypointModules(entrypoint: string): string[] { + const source = readFileSync(entrypoint, "utf8"); + const modules: string[] = []; + + const sourceFile = ts.createSourceFile( + "/caplets-code-mode-public-api.ts", + source, + ts.ScriptTarget.ES2022, + true, + ts.ScriptKind.TS, + ); + + for (const statement of sourceFile.statements) { + if ( + (ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) && + statement.moduleSpecifier && + ts.isStringLiteral(statement.moduleSpecifier) + ) { + modules.push(statement.moduleSpecifier.text); + } + } + + return modules.sort(); +} + function resolveModule(baseDir: string, specifier: string): string | undefined { const exact = resolve(baseDir, specifier); const candidates = [exact, `${exact}.ts`, resolve(exact, "index.ts")]; diff --git a/packages/core/test/code-mode-runner.test.ts b/packages/core/test/code-mode-runner.test.ts index d79ca8e3..f18289f0 100644 --- a/packages/core/test/code-mode-runner.test.ts +++ b/packages/core/test/code-mode-runner.test.ts @@ -1,9 +1,11 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { runCodeMode } from "../src/code-mode/runner"; +import { CodeModeJournalStore } from "../src/code-mode/journal"; import { CodeModeLogStore } from "../src/code-mode/logs"; +import { CodeModeSessionManager } from "../src/code-mode/sessions"; import type { NativeCapletTool, NativeCapletsService } from "../src/native/service"; function service(): NativeCapletsService { @@ -151,6 +153,96 @@ describe("runCodeMode", () => { }); }); + it("rejects session ids when no session manager is available", async () => { + const native = service(); + const result = await runCodeMode({ + code: "return missingFromSession;", + service: native, + sessionId: "expired-session", + }); + + expect(result).toMatchObject({ + ok: false, + error: { code: "SESSION_NOT_FOUND" }, + diagnostics: [], + meta: { sessionId: "expired-session", sessionStatus: null }, + }); + expect(native.execute).not.toHaveBeenCalled(); + }); + + it("journals Caplet execution as side-effecting recovery history", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-code-mode-runner-journal-")); + try { + const journalStore = new CodeModeJournalStore({ + stateDir: dir, + secret: "test-secret", + now: () => new Date("2026-06-17T12:00:00.000Z"), + }); + const sessionManager = new CodeModeSessionManager({ idGenerator: () => "session-journal" }); + const result = await runCodeMode({ + code: 'return await caplets.github.callTool("listIssues", { state: "open" });', + service: service(), + sessionManager, + journalStore, + }); + + expect(result.ok).toBe(true); + const recovery = await journalStore.readRecovery({ + recoveryRef: result.meta.recoveryRef ?? "", + }); + + expect(recovery.entries).toEqual([ + expect.objectContaining({ + recoveryClassification: "side_effecting", + code: expect.stringContaining("caplets.github.callTool"), + }), + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("does not replace Code Mode results when journal storage fails", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-code-mode-runner-journal-link-")); + const outside = mkdtempSync(join(tmpdir(), "caplets-code-mode-runner-journal-outside-")); + try { + mkdirSync(join(dir, "code-mode")); + symlinkSync(outside, join(dir, "code-mode", "journal")); + const journalStore = new CodeModeJournalStore({ + stateDir: dir, + secret: "test-secret", + }); + const native = service(); + const sessionManager = new CodeModeSessionManager({ + idGenerator: () => "session-journal-failure", + }); + const success = await runCodeMode({ + code: 'return await caplets.github.callTool("listIssues", { state: "open" });', + service: native, + sessionManager, + journalStore, + }); + const diagnostic = await runCodeMode({ + code: 'await caplets.github.call("listIssues", {});', + service: service(), + sessionManager, + sessionId: "session-journal-failure", + journalStore, + }); + + expect(success).toMatchObject({ ok: true }); + expect(native.execute).toHaveBeenCalled(); + expect(diagnostic).toMatchObject({ + ok: false, + error: { code: "diagnostic_blocked" }, + }); + sessionManager.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + rmSync(outside, { recursive: true, force: true }); + } + }); + it("fails non-JSON return values with a structured serialization diagnostic", async () => { const result = await runCodeMode({ code: "return 1n;", @@ -162,4 +254,28 @@ describe("runCodeMode", () => { "SERIALIZATION_ERROR", ); }); + + it("records successful session cells before serialization errors return", async () => { + const manager = new CodeModeSessionManager({ idGenerator: () => "session-serialization" }); + try { + const first = await runCodeMode({ + code: "function helper() { return 1n; }\nreturn helper();", + service: service(), + sessionManager: manager, + runtimeScope: "test", + }); + const second = await runCodeMode({ + code: "return typeof helper;", + service: service(), + sessionManager: manager, + sessionId: "session-serialization", + runtimeScope: "test", + }); + + expect(first).toMatchObject({ ok: false, error: { code: "SERIALIZATION_ERROR" } }); + expect(second).toMatchObject({ ok: true, value: "function", diagnostics: [] }); + } finally { + manager.close(); + } + }); }); diff --git a/packages/core/test/code-mode-session.test.ts b/packages/core/test/code-mode-session.test.ts new file mode 100644 index 00000000..0f6c2c69 --- /dev/null +++ b/packages/core/test/code-mode-session.test.ts @@ -0,0 +1,3550 @@ +import { describe, expect, it, vi } from "vitest"; +import { + CodeModeDiagnosticsSession, + diagnoseCodeModeTypeScript, +} from "../src/code-mode/diagnostics"; +import { QuickJsCodeModeSandbox } from "../src/code-mode/sandbox"; + +const invoke = vi.fn(async () => ({ ok: true })); + +describe("QuickJsCodeModeSandbox sessions", () => { + it("reuses named helpers across cells in one session", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "function double(value: number) { return value * 2; }\nreturn double(3);", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return double(5);", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: 6 }); + expect(second).toMatchObject({ ok: true, value: 10 }); + } finally { + session.dispose(); + } + }); + + it("keeps one-shot runs draining pending timer work before returning", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const localInvoke = vi.fn(async () => ({ ok: true })); + + const result = await sandbox.run({ + code: "setTimeout(() => { void caplets.alpha.inspect(); }, 0);\nreturn 'done';", + capletIds: ["alpha"], + timeoutMs: 1_000, + invoke: localInvoke, + }); + + expect(result).toMatchObject({ ok: true, value: "done" }); + expect(localInvoke).toHaveBeenCalledWith( + expect.objectContaining({ capletId: "alpha", method: "inspect" }), + ); + }); + + it("keeps a session reusable after a runtime error without global changes", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const failed = await session.run({ + code: 'throw new Error("boom");', + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const recovered = await session.run({ + code: "return 2 + 2;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(recovered).toMatchObject({ ok: true, value: 4 }); + } finally { + session.dispose(); + } + }); + + it("preserves same-cell closures for reusable named helpers", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "const factor = 2;\nfunction double(v) { return v * factor; }\nreturn double(3);", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return double(4);", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: 6 }); + expect(second).toMatchObject({ ok: true, value: 8 }); + } finally { + session.dispose(); + } + }); + + it("preserves same-cell function hoisting for reusable named helpers", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const result = await session.run({ + code: "const before = double(3);\nfunction double(v) { return v * 2; }\nreturn before;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const reused = await session.run({ + code: "return double(4);", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(result).toMatchObject({ ok: true, value: 6 }); + expect(reused).toMatchObject({ ok: true, value: 8 }); + } finally { + session.dispose(); + } + }); + + it("persists helpers declared after an early return", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "return double(3);\nfunction double(v) { return v * 2; }", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return double(4);", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: 6 }); + expect(second).toMatchObject({ ok: true, value: 8 }); + } finally { + session.dispose(); + } + }); + + it("persists helpers before returns inside top-level control flow", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "function double(v) { return v * 2; }\nif (true) return double(3);\nreturn 0;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return double(4);", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: 6 }); + expect(second).toMatchObject({ ok: true, value: 8 }); + } finally { + session.dispose(); + } + }); + + it("persists same-cell helper reassignments", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "function f() { return 1; }\nf = () => 2;\nreturn f();", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return f();", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: 2 }); + expect(second).toMatchObject({ ok: true, value: 2 }); + } finally { + session.dispose(); + } + }); + + it("persists helper reassignments inside return expressions", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "function f() { return 1; }\nreturn (f = () => 2, f());", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return f();", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: 2 }); + expect(second).toMatchObject({ ok: true, value: 2 }); + } finally { + session.dispose(); + } + }); + + it("persists helper reassignments from finally blocks", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "function f() { return 1; }\ntry { return f(); } finally { f = () => 2; }", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return f();", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: 1 }); + expect(second).toMatchObject({ ok: true, value: 2 }); + } finally { + session.dispose(); + } + }); + + it("reuses var bindings across cells in one session", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "var counter = 1;\nreturn counter;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "counter += 1;\nreturn counter;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: 1 }); + expect(second).toMatchObject({ ok: true, value: 2 }); + } finally { + session.dispose(); + } + }); + + it("persists mutations to existing var bindings", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "var counter = 1;\nreturn counter;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "counter += 1;\nreturn counter;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const third = await session.run({ + code: "return counter;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: 1 }); + expect(second).toMatchObject({ ok: true, value: 2 }); + expect(third).toMatchObject({ ok: true, value: 2 }); + } finally { + session.dispose(); + } + }); + + it("keeps existing persisted vars across same-cell var redeclarations", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var x = 1;\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const redeclared = await session.run({ + code: "var x;\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const incremented = await session.run({ + code: "var x = x + 1;\nif (true) { let x = 0; }\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(redeclared).toMatchObject({ ok: true, value: 1 }); + expect(incremented).toMatchObject({ ok: true, value: 2 }); + } finally { + session.dispose(); + } + }); + + it("does not snapshot cell-local lexical shadows over persisted vars", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var x = 1;\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const shadowed = await session.run({ + code: "let x = 2;\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const persisted = await session.run({ + code: "return x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(shadowed).toMatchObject({ ok: true, value: 2 }); + expect(persisted).toMatchObject({ ok: true, value: 1 }); + } finally { + session.dispose(); + } + }); + + it("does not snapshot block-local lexical shadows over persisted vars", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var x = 1;\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const shadowed = await session.run({ + code: "if (true) { let x = 2; return x; }\nreturn 0;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const persisted = await session.run({ + code: "return x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(shadowed).toMatchObject({ ok: true, value: 2 }); + expect(persisted).toMatchObject({ ok: true, value: 1 }); + } finally { + session.dispose(); + } + }); + + it("persists mutations to existing helper bindings", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "function f() { return 1; }\nreturn f();", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "f = () => 3;\nreturn f();", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const third = await session.run({ + code: "return f();", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(second).toMatchObject({ ok: true, value: 3 }); + expect(third).toMatchObject({ ok: true, value: 3 }); + } finally { + session.dispose(); + } + }); + + it("reuses top-level awaited var initializers across cells", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "var value = await Promise.resolve(3);\nreturn value;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return value;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: 3 }); + expect(second).toMatchObject({ ok: true, value: 3 }); + } finally { + session.dispose(); + } + }); + + it("reuses destructured top-level awaited var initializers across cells", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "var { value } = await Promise.resolve({ value: 3 });\nreturn value;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return value;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: 3 }); + expect(second).toMatchObject({ ok: true, value: 3 }); + } finally { + session.dispose(); + } + }); + + it("reuses var bindings declared inside top-level control flow", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "if (true) { var inner = 7; }\nreturn inner;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return inner;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: 7 }); + expect(second).toMatchObject({ ok: true, value: 7 }); + } finally { + session.dispose(); + } + }); + + it("reuses var bindings declared in for initializers", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "for (var i = 0; i < 3; i += 1) {}\nreturn i;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return i;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: 3 }); + expect(second).toMatchObject({ ok: true, value: 3 }); + } finally { + session.dispose(); + } + }); + + it("preserves strict-mode this semantics in session cells", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const result = await session.run({ + code: "function f() { return this === undefined; }\nreturn f();", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(result).toMatchObject({ ok: true, value: true }); + } finally { + session.dispose(); + } + }); + + it("keeps caplets read-only like one-shot cells", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "caplets = null;\nreturn caplets;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return typeof caplets.debug.readLogs;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: false }); + expect(first.ok === false ? first.error : "").toContain("read-only"); + expect(second).toMatchObject({ ok: true, value: "function" }); + } finally { + session.dispose(); + } + }); + + it("keeps debug helpers additive on a real debug Caplet handle", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + const localInvoke = vi.fn(async (input) => ({ + capletId: input.capletId, + method: input.method, + })); + try { + const result = await session.run({ + code: "return { inspect: await caplets.debug.inspect(), readLogs: typeof caplets.debug.readLogs };", + capletIds: ["debug"], + timeoutMs: 1_000, + invoke: localInvoke, + }); + + expect(result).toMatchObject({ + ok: true, + value: { + inspect: { capletId: "debug", method: "inspect" }, + readLogs: "function", + }, + }); + } finally { + session.dispose(); + } + }); + + it("allows synthetic debug recovery reads without an active debug Caplet", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + const localInvoke = vi.fn(async (input) => ({ + capletId: input.capletId, + method: input.method, + args: input.args, + })); + try { + const result = await session.run({ + code: 'return await caplets.debug.readRecovery({ recoveryRef: "recovery-1" });', + capletIds: [], + timeoutMs: 1_000, + invoke: localInvoke, + }); + + expect(result).toMatchObject({ + ok: true, + value: { + capletId: "debug", + method: "readRecovery", + args: [{ recoveryRef: "recovery-1" }], + }, + }); + expect(localInvoke).toHaveBeenCalledWith( + expect.objectContaining({ capletId: "debug", method: "readRecovery" }), + ); + } finally { + session.dispose(); + } + }); + + it("preserves duplicate var and let binding errors inside a cell", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const result = await session.run({ + code: "var x;\nlet x = 2;\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(result).toMatchObject({ ok: false }); + } finally { + session.dispose(); + } + }); + + it("removes caplet handles that are unavailable in later session cells", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "return typeof caplets.alpha;", + capletIds: ["alpha"], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return typeof caplets.alpha;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: "object" }); + expect(second).toMatchObject({ ok: true, value: "undefined" }); + } finally { + session.dispose(); + } + }); + + it("prevents user code from replacing internal bridge handle factories", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "globalThis.__caplets_handle = () => ({ marker: 'fake' });\nreturn typeof globalThis.__caplets_handle;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return caplets.alpha.id;", + capletIds: ["alpha"], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: false }); + expect(second).toMatchObject({ ok: true, value: "alpha" }); + } finally { + session.dispose(); + } + }); + + it("prevents user code from replacing the internal invoke bridge", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + const localInvoke = vi.fn(async () => ({ ok: true, source: "host" })); + try { + const first = await session.run({ + code: "globalThis.__caplets_invoke = () => JSON.stringify({ ok: true, source: 'fake' });\nreturn typeof globalThis.__caplets_invoke;", + capletIds: [], + timeoutMs: 1_000, + invoke: localInvoke, + }); + const second = await session.run({ + code: "return caplets.alpha.inspect();", + capletIds: ["alpha"], + timeoutMs: 1_000, + invoke: localInvoke, + }); + + expect(first).toMatchObject({ ok: true, value: "function" }); + expect(second).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + expect(localInvoke).not.toHaveBeenCalled(); + } finally { + session.dispose(); + } + }); + + it("prevents user code from replacing the internal JSON invoke bridge", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + const localInvoke = vi.fn(async () => ({ ok: true, source: "host" })); + try { + const first = await session.run({ + code: "globalThis.__caplets_invoke_json = () => ({ ok: true, source: 'fake' });\nreturn typeof globalThis.__caplets_invoke_json;", + capletIds: [], + timeoutMs: 1_000, + invoke: localInvoke, + }); + const second = await session.run({ + code: "return caplets.alpha.inspect();", + capletIds: ["alpha"], + timeoutMs: 1_000, + invoke: localInvoke, + }); + + expect(first).toMatchObject({ ok: false }); + expect(second).toMatchObject({ ok: true, value: { ok: true, source: "host" } }); + expect(localInvoke).toHaveBeenCalledWith( + expect.objectContaining({ capletId: "alpha", method: "inspect" }), + ); + } finally { + session.dispose(); + } + }); + + it("does not let user code poison the result channel", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const poisoned = await session.run({ + code: "Object.defineProperty(globalThis, '__caplets_result', { configurable: false, writable: false, value: Promise.resolve('masked') });\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "throw new Error('still failed');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(poisoned).toMatchObject({ ok: false }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("does not let user code spoof failed cells through Promise then poisoning", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var state = { count: 0 };\nreturn state.count;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const poisoned = await session.run({ + code: "Promise.prototype.then = function(onFulfilled) { onFulfilled('masked'); return { then() {} }; };\nstate.count = 1;\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return state.count;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(poisoned).toMatchObject({ ok: false }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("does not let user code replace the promise observer by enumeration", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var state = { count: 0 };\nreturn state.count;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const poisoned = await session.run({ + code: "const key = Reflect.ownKeys(globalThis).find((k) => String(k).startsWith('__caplets_observe_'));\nObject.defineProperty(globalThis, key, { configurable: true, value: () => ({ settled: true, value: 'masked' }) });\nstate.count = 1;\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return state.count;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(poisoned).toMatchObject({ ok: false }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("prevents user code from replacing the internal log bridge", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "globalThis.__caplets_log = () => undefined;\nreturn 'mutated';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "console.log('host log');\nreturn 'ok';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: false }); + expect(second).toMatchObject({ + ok: true, + value: "ok", + logs: [expect.objectContaining({ level: "log", message: "host log" })], + }); + } finally { + session.dispose(); + } + }); + + it("rejects invalid direct invoke bridge methods", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + const localInvoke = vi.fn(async () => ({ ok: true })); + try { + const result = await session.run({ + code: "return await __caplets_invoke_json('debug', 'callTool', []);", + capletIds: [], + timeoutMs: 1_000, + invoke: localInvoke, + }); + + expect(result).toMatchObject({ ok: false }); + expect(result.ok === false ? result.error : "").toContain("Method callTool is not available"); + expect(localInvoke).not.toHaveBeenCalled(); + } finally { + session.dispose(); + } + }); + + it("rejects debug-only methods on direct non-debug caplet bridge calls", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + const localInvoke = vi.fn(async () => ({ ok: true })); + try { + const result = await session.run({ + code: "return await __caplets_invoke_json('alpha', 'readLogs', []);", + capletIds: ["alpha"], + timeoutMs: 1_000, + invoke: localInvoke, + }); + + expect(result).toMatchObject({ ok: false }); + expect(result.ok === false ? result.error : "").toContain("Method readLogs is not available"); + expect(localInvoke).not.toHaveBeenCalled(); + } finally { + session.dispose(); + } + }); + + it("prevents user code from corrupting host result parsing", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + const localInvoke = vi.fn(async () => ({ ok: true, source: "host" })); + try { + const first = await session.run({ + code: "JSON.parse = () => ({ ok: true, source: 'fake' });\nreturn 'mutated';", + capletIds: [], + timeoutMs: 1_000, + invoke: localInvoke, + }); + const second = await session.run({ + code: "return caplets.alpha.inspect();", + capletIds: ["alpha"], + timeoutMs: 1_000, + invoke: localInvoke, + }); + + expect(first).toMatchObject({ ok: true, value: "mutated" }); + expect(second).toMatchObject({ ok: true, value: { ok: true, source: "host" } }); + } finally { + session.dispose(); + } + }); + + it("rejects overlapping runs on the same session", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + const firstInvoke = vi.fn(async () => ({ run: "first" })); + const secondInvoke = vi.fn(async () => ({ run: "second" })); + try { + const firstPromise = session.run({ + code: "await new Promise((resolve) => setTimeout(resolve, 20));\nreturn await caplets.alpha.inspect();", + capletIds: ["alpha"], + timeoutMs: 1_000, + invoke: firstInvoke, + }); + const second = await session.run({ + code: "return await caplets.alpha.inspect();", + capletIds: ["alpha"], + timeoutMs: 1_000, + invoke: secondInvoke, + }); + const first = await firstPromise; + + expect(second).toMatchObject({ + ok: false, + error: "Code Mode session is already running.", + }); + expect(first).toMatchObject({ ok: true, value: { run: "first" } }); + expect(firstInvoke).toHaveBeenCalledWith( + expect.objectContaining({ capletId: "alpha", method: "inspect" }), + ); + expect(secondInvoke).not.toHaveBeenCalled(); + } finally { + session.dispose(); + } + }); + + it("defers disposal until an active run settles", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const running = session.run({ + code: "await new Promise((resolve) => setTimeout(resolve, 20));\nreturn 'settled';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + session.dispose(); + const result = await running; + const next = await session.run({ + code: "return 'after-dispose';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(result).toMatchObject({ ok: true, value: "settled" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("restores platform globals between session cells", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "fetch = async () => 'fake';\nreturn await fetch('https://example.com');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "const f = fetch;\nreturn await f('https://example.com');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: "fake" }); + expect(second).toMatchObject({ ok: false }); + expect(second.ok === false ? second.error : "").toContain("Direct fetch is not available"); + } finally { + session.dispose(); + } + }); + + it("restores platform globals with protected intrinsics", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "Object.defineProperty = (target) => target;\nfetch = async () => 'fake';\nreturn 'poisoned';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "const f = fetch;\nreturn await f('https://example.com');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: "poisoned" }); + expect(second).toMatchObject({ ok: false }); + expect(second.ok === false ? second.error : "").toContain("Direct fetch is not available"); + } finally { + session.dispose(); + } + }); + + it("keeps platform snapshots private between session cells", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "globalThis.__caplets_platform_snapshot = { fetch: async () => 'fake' };\nreturn 'poisoned';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "const f = fetch;\nreturn await f('https://example.com');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: "poisoned" }); + expect(second).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("keeps bare platform snapshots private between session cells", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "__caplets_platform_snapshot.fetch = async () => 'fake';\nreturn 'poisoned';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "const f = fetch;\nreturn await f('https://example.com');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: false }); + expect(second).toMatchObject({ ok: false }); + expect(second.ok === false ? second.error : "").toContain("Direct fetch is not available"); + } finally { + session.dispose(); + } + }); + + it("restores platform prototypes between session cells", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "URL.prototype.toString = () => 'fake-url';\nreturn new URL('https://example.com').toString();", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return new URL('https://example.com').toString();", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: "fake-url" }); + expect(second).toMatchObject({ ok: true, value: "https://example.com/" }); + } finally { + session.dispose(); + } + }); + + it("restores intrinsic prototypes between session cells", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "Array.prototype.map = function() { return ['poisoned']; };\nObject.prototype.extra = 'polluted';\nreturn [1, 2].map((x) => x * 2);", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return { mapped: [1, 2].map((x) => x * 2), extra: ({}).extra ?? null };", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: ["poisoned"] }); + expect(second).toMatchObject({ ok: true, value: { mapped: [2, 4], extra: null } }); + } finally { + session.dispose(); + } + }); + + it("restores standard intrinsic objects between session cells", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "JSON.parse = () => ({ poisoned: true });\nMath.max = () => -1;\nreturn JSON.parse('{\"ok\":true}');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return { parsed: JSON.parse('{\"ok\":true}'), max: Math.max(1, 2) };", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: { poisoned: true } }); + expect(second).toMatchObject({ ok: true, value: { parsed: { ok: true }, max: 2 } }); + } finally { + session.dispose(); + } + }); + + it("restores runtime globals that are not in the static platform allowlist", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "globalThis.eval = () => 123;\nAggregateError.prototype.pwned = 7;\nFloat32Array.prototype.floatLeak = 8;\nreturn { evalResult: globalThis.eval('1+1'), aggregate: new AggregateError([]).pwned, float: new Float32Array(1).floatLeak };", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return { evalResult: eval('1+1'), aggregate: new AggregateError([]).pwned ?? 'clean', float: new Float32Array(1).floatLeak ?? 'clean' };", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ + ok: true, + value: { evalResult: 123, aggregate: 7, float: 8 }, + }); + expect(second).toMatchObject({ + ok: true, + value: { evalResult: 2, aggregate: "clean", float: "clean" }, + }); + } finally { + session.dispose(); + } + }); + + it("restores primitive constructor prototypes between session cells", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "String.prototype.includes = () => true;\nNumber.prototype.toFixed = () => 'poison';\nreturn { string: 'a'.includes('z'), number: (1).toFixed() };", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return { string: 'a'.includes('z'), number: (1).toFixed() };", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: { string: true, number: "poison" } }); + expect(second).toMatchObject({ ok: true, value: { string: false, number: "1" } }); + } finally { + session.dispose(); + } + }); + + it("restores platform prototype chains between session cells", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "Object.setPrototypeOf(Array.prototype, { inheritedPoison: 42 });\nObject.setPrototypeOf(String.prototype, { pwned() { return 'yes'; } });\nreturn { array: [1].inheritedPoison, string: 'a'.pwned() };", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return { array: [2].inheritedPoison ?? 'clean', string: typeof ''.pwned === 'function' ? ''.pwned() : 'clean' };", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: { array: 42, string: "yes" } }); + expect(second).toMatchObject({ ok: true, value: { array: "clean", string: "clean" } }); + } finally { + session.dispose(); + } + }); + + it("restores global prototype changes between session cells", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "Object.setPrototypeOf(globalThis, { inheritedLeak: 77 });\nreturn inheritedLeak;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return globalThis.inheritedLeak ?? 'clean';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: 77 }); + expect(second).toMatchObject({ ok: true, value: "clean" }); + } finally { + session.dispose(); + } + }); + + it("restores existing symbol global descriptor mutations between session cells", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "Object.defineProperty(globalThis, Symbol.toStringTag, { value: 'sticky', configurable: false });\nreturn Object.prototype.toString.call(globalThis);", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return Object.prototype.toString.call(globalThis);", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: "[object sticky]" }); + expect(second).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells mutate existing symbol globals", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const failed = await session.run({ + code: "Object.defineProperty(globalThis, Symbol.toStringTag, { value: 'failed-sticky', configurable: false });\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return Object.prototype.toString.call(globalThis);", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(second).toMatchObject({ ok: false }); + expect(second.ok === false ? second.error : "").toContain( + "Code Mode session state is corrupted", + ); + } finally { + session.dispose(); + } + }); + + it("restores deleted existing symbol globals between session cells", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "delete globalThis[Symbol.toStringTag];\nreturn Object.prototype.toString.call(globalThis);", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return Object.prototype.toString.call(globalThis);", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: "[object Object]" }); + expect(second).toMatchObject({ ok: true, value: "[object global]" }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells change the global prototype", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const failed = await session.run({ + code: "Object.setPrototypeOf(globalThis, { inheritedLeak: 88 });\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return globalThis.inheritedLeak ?? 'clean';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(second).toMatchObject({ ok: true, value: "clean" }); + } finally { + session.dispose(); + } + }); + + it("disposes after hidden intrinsic prototypes become non-restorable", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "Object.defineProperty(Object.getPrototypeOf(async function(){}), 'pwned', { value: 7, configurable: false });\nreturn 'ok';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return (async function(){}).pwned ?? 'clean';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: "ok" }); + expect(second).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("restores hidden parent intrinsic prototypes between session cells", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]())).pwned = 7;\nObject.getPrototypeOf(Int8Array.prototype).typedLeak = 42;\nreturn { iterator: Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]())).pwned, typed: new Int8Array(1).typedLeak };", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return { iterator: Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]())).pwned ?? 'clean', typed: new Uint8Array(1).typedLeak ?? 'clean' };", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: { iterator: 7, typed: 42 } }); + expect(second).toMatchObject({ ok: true, value: { iterator: "clean", typed: "clean" } }); + } finally { + session.dispose(); + } + }); + + it("restores generator and regexp iterator hidden prototypes between session cells", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "Object.getPrototypeOf(Object.getPrototypeOf((function*(){})())).pwned = 7;\nObject.getPrototypeOf(Object.getPrototypeOf((async function*(){})())).asyncPwned = 8;\nObject.getPrototypeOf('a'.matchAll(/a/g)).regexPwned = 9;\nreturn { generator: Object.getPrototypeOf(Object.getPrototypeOf((function*(){})())).pwned, asyncGenerator: Object.getPrototypeOf(Object.getPrototypeOf((async function*(){})())).asyncPwned, regexp: Object.getPrototypeOf('a'.matchAll(/a/g)).regexPwned };", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return { generator: Object.getPrototypeOf(Object.getPrototypeOf((function*(){})())).pwned ?? 'clean', asyncGenerator: Object.getPrototypeOf(Object.getPrototypeOf((async function*(){})())).asyncPwned ?? 'clean', regexp: Object.getPrototypeOf('b'.matchAll(/b/g)).regexPwned ?? 'clean' };", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ + ok: true, + value: { generator: 7, asyncGenerator: 8, regexp: 9 }, + }); + expect(second).toMatchObject({ + ok: true, + value: { generator: "clean", asyncGenerator: "clean", regexp: "clean" }, + }); + } finally { + session.dispose(); + } + }); + + it("restores hidden intrinsic constructor objects between session cells", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "Object.getPrototypeOf(Int8Array).pwned = 7;\nObject.getPrototypeOf(async function(){}).constructor.asyncPwned = 8;\nObject.getPrototypeOf(function*(){}).constructor.generatorPwned = 9;\nObject.getPrototypeOf(async function*(){}).constructor.asyncGeneratorPwned = 10;\nreturn { typed: Object.getPrototypeOf(Uint8Array).pwned, async: Object.getPrototypeOf(async function(){}).constructor.asyncPwned, generator: Object.getPrototypeOf(function*(){}).constructor.generatorPwned, asyncGenerator: Object.getPrototypeOf(async function*(){}).constructor.asyncGeneratorPwned };", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return { typed: Object.getPrototypeOf(Float32Array).pwned ?? 'clean', async: Object.getPrototypeOf(async function(){}).constructor.asyncPwned ?? 'clean', generator: Object.getPrototypeOf(function*(){}).constructor.generatorPwned ?? 'clean', asyncGenerator: Object.getPrototypeOf(async function*(){}).constructor.asyncGeneratorPwned ?? 'clean' };", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ + ok: true, + value: { typed: 7, async: 8, generator: 9, asyncGenerator: 10 }, + }); + expect(second).toMatchObject({ + ok: true, + value: { typed: "clean", async: "clean", generator: "clean", asyncGenerator: "clean" }, + }); + } finally { + session.dispose(); + } + }); + + it("disposes with a clear error when frozen prototypes block platform restore", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "Array.prototype.map = function() { return ['poisoned']; };\nObject.freeze(Array.prototype);\nreturn [1].map((x) => x);", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return [1, 2].map((x) => x * 2);", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const third = await session.run({ + code: "return 'after-corruption';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: ["poisoned"] }); + expect(second).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + expect(third).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes with a clear error when non-extensible prototypes block platform restore", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "Object.preventExtensions(Array.prototype);\nreturn Object.isExtensible(Array.prototype);", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return [1, 2].map((x) => x * 2);", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: false }); + expect(second).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after successful cells make platform prototypes non-restorable", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "Object.preventExtensions(Array.prototype);\nreturn 'ok';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return [1].length;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: "ok" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells add non-configurable platform prototype properties", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const failed = await session.run({ + code: "Object.defineProperty(Array.prototype, 'stickyLeak', { configurable: false, value: 7 });\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return [2].stickyLeak ?? 'clean';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(next).toMatchObject({ ok: false }); + expect(next.ok === false ? next.error : "").toContain("Code Mode session state is corrupted"); + } finally { + session.dispose(); + } + }); + + it("prevents direct invoke bridge calls after the caplet is unavailable", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + const localInvoke = vi.fn(async ({ capletId }) => ({ ok: true, capletId })); + try { + const first = await session.run({ + code: "var raw = (id) => __caplets_invoke_json(id, 'inspect', []);\nreturn await raw('alpha');", + capletIds: ["alpha"], + timeoutMs: 1_000, + invoke: localInvoke, + }); + const reused = await session.run({ + code: "return await raw('alpha');", + capletIds: [], + timeoutMs: 1_000, + invoke: localInvoke, + }); + + expect(first).toMatchObject({ ok: true, value: { ok: true, capletId: "alpha" } }); + expect(reused).toMatchObject({ ok: false }); + expect(reused.ok === false ? reused.error : "").toContain( + "Caplet alpha is not available in this Code Mode session cell.", + ); + expect(localInvoke).toHaveBeenCalledTimes(1); + } finally { + session.dispose(); + } + }); + + it("disposes after successful cells make globalThis non-extensible", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "queueMicrotask(() => Object.preventExtensions(globalThis));\nawait Promise.resolve();\nreturn 'locked';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return 2 + 2;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: "locked" }); + expect(second).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("restores timer platform globals between session cells", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "setTimeout = () => 123;\nreturn setTimeout();", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return await new Promise((resolve) => setTimeout(() => resolve('timer'), 0));", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: 123 }); + expect(second).toMatchObject({ ok: true, value: "timer" }); + } finally { + session.dispose(); + } + }); + + it("clears unawaited timers from successful cells before later runs", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + const localInvoke = vi.fn(async () => ({ ok: true })); + try { + const first = await session.run({ + code: "setTimeout(() => {}, 500);\nreturn 'first';", + capletIds: [], + timeoutMs: 1_000, + invoke: localInvoke, + }); + const second = await session.run({ + code: "return await caplets.alpha.inspect();", + capletIds: ["alpha"], + timeoutMs: 100, + invoke: localInvoke, + }); + + expect(first).toMatchObject({ ok: true, value: "first" }); + expect(second).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("restores mutable platform object globals between session cells", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "crypto.randomUUID = () => 'fake';\nreturn crypto.randomUUID();", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return crypto.randomUUID();", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: "fake" }); + expect(second).toMatchObject({ + ok: true, + value: expect.stringMatching(/^[0-9a-f-]{36}$/u), + }); + } finally { + session.dispose(); + } + }); + + it("prevents persisted caplet handles from invoking after the caplet is unavailable", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + const localInvoke = vi.fn(async () => ({ ok: true })); + try { + const first = await session.run({ + code: "var saved = caplets.alpha;\nreturn saved.id;", + capletIds: ["alpha"], + timeoutMs: 1_000, + invoke: localInvoke, + }); + const hidden = await session.run({ + code: "return typeof caplets.alpha;", + capletIds: [], + timeoutMs: 1_000, + invoke: localInvoke, + }); + const reused = await session.run({ + code: "return await saved.inspect();", + capletIds: [], + timeoutMs: 1_000, + invoke: localInvoke, + }); + + expect(first).toMatchObject({ ok: true, value: "alpha" }); + expect(hidden).toMatchObject({ ok: true, value: "undefined" }); + expect(reused).toMatchObject({ ok: false }); + expect(reused.ok === false ? reused.error : "").toContain( + "Caplet alpha is not available in this Code Mode session cell.", + ); + expect(localInvoke).not.toHaveBeenCalled(); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells introduce unreadable new var bindings", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const failed = await session.run({ + code: "var leaked = 7;\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return typeof leaked;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells add globals", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const failed = await session.run({ + code: "globalThis.leaked = 7;\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return leaked;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells forge the global checkpoint", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const failed = await session.run({ + code: "globalThis.leaked = 7;\nglobalThis.__caplets_global_checkpoint = new Set(['leaked']);\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return globalThis.leaked ?? 'clean';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells poison global key enumeration", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const failed = await session.run({ + code: "Reflect.ownKeys = () => [];\nglobalThis.leaked = 7;\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return globalThis.leaked ?? 'clean';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells poison key filtering", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const failed = await session.run({ + code: "Array.prototype.filter = () => [];\nglobalThis.leaked = 7;\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return globalThis.leaked ?? 'clean';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells add caplets-prefixed globals", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const failed = await session.run({ + code: "globalThis.__caplets_user_leak = 7;\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return globalThis.__caplets_user_leak ?? 'clean';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells add symbol globals", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const failed = await session.run({ + code: "globalThis[Symbol.for('leak')] = 7;\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return globalThis[Symbol.for('leak')] ?? 'clean';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells add symbol globals that stringify like existing symbols", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const failed = await session.run({ + code: "globalThis[Symbol.for('Symbol.toStringTag')] = 'leaked';\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return globalThis[Symbol.for('Symbol.toStringTag')] ?? 'clean';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("keeps persisted bindings after clean runtime errors", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var counter = 1;\nreturn counter;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const failed = await session.run({ + code: "throw new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const recovered = await session.run({ + code: "return counter;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(recovered).toMatchObject({ ok: true, value: 1 }); + } finally { + session.dispose(); + } + }); + + it("does not restore from forged persistent checkpoints", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var x = 1;\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const failed = await session.run({ + code: "globalThis.__caplets_persist_checkpoint = { x: 99 };\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("does not expose a usable persistent checkpoint helper to user code", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var x = 1;\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const failed = await session.run({ + code: "__caplets_snapshot_persist('user-token', ['x']);", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false }); + expect(failed.ok === false ? failed.error : "").toContain( + "Code Mode persistence checkpoint token is invalid", + ); + expect(next).toMatchObject({ ok: true, value: 1 }); + } finally { + session.dispose(); + } + }); + + it("does not expose persistent checkpoint internals to user code", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var x = 1;\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const failed = await session.run({ + code: "__caplets_persist_checkpoint_state.current = Object.fromEntries([['x', 99]]);\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const token = await session.run({ + code: "return typeof __caplets_persist_checkpoint_token;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false }); + expect(failed.ok === false ? failed.error : "").toContain( + "__caplets_persist_checkpoint_state", + ); + expect(token).toMatchObject({ ok: true, value: "undefined" }); + expect(next).toMatchObject({ ok: true, value: 1 }); + } finally { + session.dispose(); + } + }); + + it("keeps persisted bindings that shadow platform globals", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "var JSON = 1;\nreturn JSON;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return JSON;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: 1 }); + expect(second).toMatchObject({ ok: true, value: 1 }); + } finally { + session.dispose(); + } + }); + + it("keeps persisted bindings that shadow dynamically snapshotted platform globals", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "var Float32Array = 'shadow';\nvar AggregateError = 'aggregate-shadow';\nreturn { float: Float32Array, aggregate: AggregateError };", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return { float: Float32Array, aggregate: AggregateError };", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ + ok: true, + value: { float: "shadow", aggregate: "aggregate-shadow" }, + }); + expect(second).toMatchObject({ + ok: true, + value: { float: "shadow", aggregate: "aggregate-shadow" }, + }); + } finally { + session.dispose(); + } + }); + + it("keeps persisted bindings named like internal return temps", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const first = await session.run({ + code: "var __caplets_return = 'persisted';\nreturn 'actual';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const second = await session.run({ + code: "return __caplets_return;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(first).toMatchObject({ ok: true, value: "actual" }); + expect(second).toMatchObject({ ok: true, value: "persisted" }); + } finally { + session.dispose(); + } + }); + + it("does not overwrite persisted bindings from catch parameter shadows", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var err = 'persisted';\nreturn err;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const caught = await session.run({ + code: "try { throw 'shadow'; } catch (err) { return err; }", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return err;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(caught).toMatchObject({ ok: true, value: "shadow" }); + expect(next).toMatchObject({ ok: true, value: "persisted" }); + } finally { + session.dispose(); + } + }); + + it("does not overwrite persisted bindings from finally lexical shadows", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var x = 1;\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const returned = await session.run({ + code: "try { return x; } finally { let x = 2; }", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(returned).toMatchObject({ ok: true, value: 1 }); + expect(next).toMatchObject({ ok: true, value: 1 }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells make globalThis non-extensible", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const failed = await session.run({ + code: "Object.preventExtensions(globalThis);\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return 2 + 2;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("drains successful cells that persist pending host promises before later runs", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + const localInvoke = vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return { ok: true }; + }); + try { + const first = await session.run({ + code: "var p = caplets.alpha.inspect();\nreturn 'stored';", + capletIds: ["alpha"], + timeoutMs: 1_000, + invoke: localInvoke, + }); + const second = await session.run({ + code: "return await p;", + capletIds: ["alpha"], + timeoutMs: 1_000, + invoke: localInvoke, + }); + + expect(first).toMatchObject({ ok: true, value: "stored" }); + expect(second).toMatchObject({ ok: true, value: { ok: true } }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells introduce new var bindings", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const failed = await session.run({ + code: "var leaked = 7;\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "leaked = 3;\nreturn leaked;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells with existing persistent bindings", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var x = 1;\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const failed = await session.run({ + code: "try { x = 2; throw new Error('boom'); } finally {}", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false }); + expect(next).toMatchObject({ ok: true, value: 1 }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells corrupt persisted global descriptors", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var x = 1;\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const failed = await session.run({ + code: "Object.defineProperty(globalThis, 'x', { writable: false });\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "x = 2;\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells that mutate persisted objects", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var state = { count: 0 };\nreturn state.count;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const failed = await session.run({ + code: "state.count = 1;\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return state.count;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells mutate persisted objects through methods", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var state = { items: [] };\nreturn state.items.length;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const failed = await session.run({ + code: "state.items.push(1);\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return state.items.length;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed nested callbacks mutate persisted objects", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var state = { count: 0 };\nreturn state.count;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const failed = await session.run({ + code: "(() => { state.count = 1; })();\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return state.count;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells mutate persisted objects through computed global access", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var state = { count: 0 };\nreturn state.count;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const failed = await session.run({ + code: "globalThis['st' + 'ate'].count = 1;\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return state.count;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells mutate persisted objects through eval", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var state = { count: 0 };\nreturn state.count;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const failed = await session.run({ + code: "eval('state.count = 1');\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return state.count;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells mutate persisted objects through indirect Function", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var state = { count: 0 };\nreturn state.count;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const failed = await session.run({ + code: "({}).constructor.constructor('state.count = 1')();\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return state.count;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells mutate persistence map descriptors", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var x = 1;\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const failed = await session.run({ + code: "Object.defineProperty(__caplets_persist, 'x', { configurable: true, get() { return 99; }, set(v) {} });\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("does not leak failed state through the internal return temp name", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const failed = await session.run({ + code: "globalThis.__caplets_return = { leaked: 7 };\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return globalThis.__caplets_return?.leaked ?? 'clean';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells write directly to the persistence map", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const failed = await session.run({ + code: "__caplets_persist.leak = 7;\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return __caplets_persist.leak ?? 'clean';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after successful persistence descriptor poisoning even with descriptor spoofing", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var x = 1;\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const poisoned = await session.run({ + code: "Object.defineProperty(__caplets_persist, 'x', { configurable: true, get() { return 99; }, set(v) {} });\nObject.getOwnPropertyDescriptor = () => ({ value: 1, writable: true, configurable: true });\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(poisoned).toMatchObject({ ok: true, value: 1 }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after lexical shadows write directly to persisted state", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var x = 'persisted';\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const shadowed = await session.run({ + code: "let x = 'shadow';\n__caplets_persist.x = 'poisoned';\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(shadowed).toMatchObject({ ok: true, value: "shadow" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after nested lexical shadows write directly to persisted state", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var x = 'persisted';\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const shadowed = await session.run({ + code: "if (true) { let x = 'shadow'; __caplets_persist.x = 'poisoned'; return x; }\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(shadowed).toMatchObject({ ok: true, value: "shadow" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after microtasks write directly to persisted state", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var x = 'persisted';\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const queued = await session.run({ + code: "queueMicrotask(() => { __caplets_persist.x = 'poisoned'; });\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(queued).toMatchObject({ ok: true, value: "persisted" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("keeps primitive state changed by drained microtasks", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var x = 1;\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const queued = await session.run({ + code: "queueMicrotask(() => { x = 2; });\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(queued).toMatchObject({ ok: true, value: 1 }); + expect(next).toMatchObject({ ok: true, value: 2 }); + } finally { + session.dispose(); + } + }); + + it("does not restore persisted names inside switch blocks shadowed by later clauses", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var x = 'persisted';\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const result = await session.run({ + code: 'const kind = "a";\nswitch (kind) { case "a": return 1; case "b": let x = 2; return x; }\nreturn 0;', + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(result).toMatchObject({ ok: true, value: 1 }); + } finally { + session.dispose(); + } + }); + + it("preserves comma-expression return values while snapshotting persisted state", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var x = 0;\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const result = await session.run({ + code: "return x = 1, x + 1;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const followup = await session.run({ + code: "return x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(result).toMatchObject({ ok: true, value: 2 }); + expect(followup).toMatchObject({ ok: true, value: 1 }); + } finally { + session.dispose(); + } + }); + + it("waits for unawaited Caplet calls before returning session results", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + const calls: string[] = []; + try { + const result = await session.run({ + code: 'void caplets.github.check();\nreturn "done";', + capletIds: ["github"], + timeoutMs: 1_000, + invoke: async (input) => { + await Promise.resolve(); + calls.push(`${input.capletId}.${input.method}`); + return { ok: true }; + }, + }); + + expect(result).toMatchObject({ ok: true, value: "done" }); + expect(calls).toEqual(["github.check"]); + } finally { + session.dispose(); + } + }); + + it("disposes when drained unawaited Caplet callbacks mutate persisted bindings", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const result = await session.run({ + code: "var x = 1;\nvoid caplets.github.check().then(() => { x = 2; });\nreturn x;", + capletIds: ["github"], + timeoutMs: 1_000, + invoke: async () => { + await Promise.resolve(); + return { ok: true }; + }, + }); + const followup = await session.run({ + code: "return x;", + capletIds: ["github"], + timeoutMs: 1_000, + invoke, + }); + + expect(result).toMatchObject({ ok: true, value: 1 }); + expect(followup).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("keeps sessions reusable when drained callbacks only mutate local shadows", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const result = await session.run({ + code: "var x = 1;\nvoid caplets.github.check().then(() => { let x = 2; x += 1; });\nreturn x;", + capletIds: ["github"], + timeoutMs: 1_000, + invoke: async () => { + await Promise.resolve(); + return { ok: true }; + }, + }); + const followup = await session.run({ + code: "return x;", + capletIds: ["github"], + timeoutMs: 1_000, + invoke, + }); + + expect(result).toMatchObject({ ok: true, value: 1 }); + expect(followup).toMatchObject({ ok: true, value: 1 }); + } finally { + session.dispose(); + } + }); + + it("disposes when named callbacks mutate persisted bindings after drained invokes", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const result = await session.run({ + code: [ + "var x = 1;", + "const update = () => { x = 2; };", + "void caplets.github.check().then(update);", + "return x;", + ].join("\n"), + capletIds: ["github"], + timeoutMs: 1_000, + invoke: async () => { + await Promise.resolve(); + return { ok: true }; + }, + }); + const followup = await session.run({ + code: "return x;", + capletIds: ["github"], + timeoutMs: 1_000, + invoke, + }); + + expect(result).toMatchObject({ ok: true, value: 1 }); + expect(followup).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after nested lexical shadows write to persisted state through computed access", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var x = 'persisted';\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const shadowed = await session.run({ + code: "if (true) { let x = 'shadow'; globalThis['__caplets_' + 'persist'].x = 'poisoned'; return x; }\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(shadowed).toMatchObject({ ok: true, value: "shadow" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after microtasks write to persisted state through computed access", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var x = 'persisted';\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const queued = await session.run({ + code: "queueMicrotask(() => { globalThis['__caplets_' + 'persist'].x = 'poisoned'; });\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(queued).toMatchObject({ ok: true, value: "persisted" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after persistence map prototype poisoning through computed access", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const poisoned = await session.run({ + code: "Object.setPrototypeOf(globalThis['__caplets_' + 'persist'], { y: 'poisoned' });\nreturn 'ok';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "var y;\nreturn y;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(poisoned).toMatchObject({ ok: true, value: "ok" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after successful cells mutate persistence map descriptors", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var x = 1;\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const poisoned = await session.run({ + code: "Object.defineProperty(__caplets_persist, 'x', { configurable: true, get() { return 99; }, set(v) {} });\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "x = 2;\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(poisoned).toMatchObject({ ok: true, value: 1 }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells try to replace the persistence map", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var x = 1;\nreturn x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const failed = await session.run({ + code: "globalThis.__caplets_persist = Object.freeze(Object.create(null));\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return x;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false }); + expect(next).toMatchObject({ ok: true, value: 1 }); + } finally { + session.dispose(); + } + }); + + it("disposes after failed cells mutate persisted objects through aliases", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "var state = { count: 0 };\nreturn state.count;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const failed = await session.run({ + code: "const alias = state;\nalias.count = 1;\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const next = await session.run({ + code: "return state.count;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("returns immediate runtime errors before pending timers time out", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const failed = await session.run({ + code: "setTimeout(() => {}, 10_000);\nthrow new Error('boom');", + capletIds: [], + timeoutMs: 100, + invoke, + }); + const next = await session.run({ + code: "return 'after pending failure';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(failed).toMatchObject({ ok: false, error: "boom" }); + expect(next).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("does not snapshot block-local function shadows over persisted helpers", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + await session.run({ + code: "function f() { return 1; }\nreturn f();", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const shadowed = await session.run({ + code: "if (true) { function f() { return 2; } return f(); }\nreturn 0;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + const persisted = await session.run({ + code: "return f();", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(shadowed).toMatchObject({ ok: true, value: 2 }); + expect(persisted).toMatchObject({ ok: true, value: 1 }); + } finally { + session.dispose(); + } + }); + + it("isolates helper and var bindings between sessions", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const firstSession = await sandbox.createSession(); + const secondSession = await sandbox.createSession(); + try { + await firstSession.run({ + code: "function hidden() { return 42; }\nvar secret = 7;\nreturn hidden() + secret;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + const result = await secondSession.run({ + code: "return typeof hidden + ':' + typeof secret;", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(result).toMatchObject({ ok: true, value: "undefined:undefined" }); + } finally { + firstSession.dispose(); + secondSession.dispose(); + } + }); + + it("cleans up timed-out sessions when disposed", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + const result = await session.run({ + code: "await new Promise((resolve) => setTimeout(resolve, 10_000));", + capletIds: [], + timeoutMs: 100, + invoke, + }); + + expect(result).toMatchObject({ ok: false }); + expect(result.ok === false ? result.error : "").toContain("timed out"); + session.dispose(); + + const fresh = await sandbox.createSession(); + try { + await expect( + fresh.run({ code: "return 'fresh';", capletIds: [], timeoutMs: 1_000, invoke }), + ).resolves.toMatchObject({ ok: true, value: "fresh" }); + } finally { + fresh.dispose(); + } + }); + + it("makes a timed-out session unusable before stale async work can leak", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + try { + const timedOut = await session.run({ + code: "setTimeout(() => { globalThis.leaked = 'stale'; }, 0);\nwhile (true) {}", + capletIds: [], + timeoutMs: 100, + invoke, + }); + const reused = await session.run({ + code: "return globalThis.leaked ?? 'clean';", + capletIds: [], + timeoutMs: 1_000, + invoke, + }); + + expect(timedOut).toMatchObject({ ok: false }); + expect(timedOut.ok === false ? timedOut.error : "").toContain("timed out"); + expect(reused).toMatchObject({ ok: false, error: "Code Mode session is disposed." }); + } finally { + session.dispose(); + } + }); + + it("keeps platform APIs equivalent in one-shot and session runs", async () => { + const sandbox = new QuickJsCodeModeSandbox(); + const session = await sandbox.createSession(); + const code = ` + const id = crypto.randomUUID(); + let timer = await new Promise((resolve) => setTimeout(() => resolve("timer"), 0)); + let fetchError = ""; + try { + await fetch("https://example.com"); + } catch (error) { + fetchError = error instanceof Error ? error.message : String(error); + } + return { id, timer, fetchError }; + `; + try { + const oneShot = await sandbox.run({ code, capletIds: [], timeoutMs: 1_000, invoke }); + const reused = await session.run({ code, capletIds: [], timeoutMs: 1_000, invoke }); + + expect(oneShot).toMatchObject({ + ok: true, + value: { + id: expect.stringMatching(/^[0-9a-f-]{36}$/u), + timer: "timer", + fetchError: expect.stringContaining("Direct fetch is not available"), + }, + }); + expect(reused).toMatchObject({ + ok: true, + value: { + id: expect.stringMatching(/^[0-9a-f-]{36}$/u), + timer: "timer", + fetchError: expect.stringContaining("Direct fetch is not available"), + }, + }); + } finally { + session.dispose(); + } + }); +}); + +describe("CodeModeDiagnosticsSession", () => { + it("allows later cells to reference helpers from prior successful cells", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + const first = diagnoseCodeModeTypeScript({ + declaration, + code: "function double(value: number) { return value * 2; }\nreturn double(3);", + session, + }); + session.recordSuccessfulCell( + "function double(value: number) { return value * 2; }\nreturn double(3);", + ); + const second = diagnoseCodeModeTypeScript({ + declaration, + code: "return double(5);", + session, + }); + + expect(first.filter((diagnostic) => diagnostic.severity === "error")).toEqual([]); + expect(second.filter((diagnostic) => diagnostic.severity === "error")).toEqual([]); + }); + + it("allows later cells to reference generic helpers from prior successful cells", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell("function id(x: T): T { return x; }\nreturn id(1);"); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: 'return id("ok");', + session, + }); + + expect(diagnostics).toEqual([]); + }); + + it("allows later cells to reference helpers with default parameters", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell("function f(x = 1): number { return x; }\nreturn f();"); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: "return f();", + session, + }); + + expect(diagnostics).toEqual([]); + }); + + it("preserves inferred primitive var types for later session diagnostics", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell("var workflowRuns = 1;\nreturn workflowRuns;"); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: "workflowRuns += 1;\nreturn workflowRuns;", + session, + }); + + expect(diagnostics).toEqual([]); + }); + + it("preserves explicit var annotations for later session diagnostics", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell('var items: string[] = [];\nitems.push("a");\nreturn items;'); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: 'items.push("b");\nreturn items.join(",");', + session, + }); + + expect(diagnostics).toEqual([]); + }); + + it("preserves inferred object and array var types for later session diagnostics", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell( + 'var summary = { count: 1, label: "one" };\nvar numbers = [1, 2, 3];', + ); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: "summary.count += numbers[0] ?? 0;\nreturn `${summary.label}:${summary.count}`;", + session, + }); + + expect(diagnostics).toEqual([]); + }); + + it("preserves checker-inferred destructured var binding types when available", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell( + 'var { count, label } = { count: 1, label: "ready" };\nreturn label;', + ); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: "count += 1;\nreturn label.toUpperCase();", + session, + }); + + expect(diagnostics).toEqual([]); + }); + + it("updates var ambient types when successful cells redeclare a binding", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell("var mutable = 1;\nreturn mutable;"); + session.recordSuccessfulCell('var mutable = "ready";\nreturn mutable;'); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: "const numeric: number = mutable;\nreturn numeric;", + session, + }); + + expect(diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "2322", + message: expect.stringContaining("not assignable to type 'number'"), + }), + ]), + ); + }); + + it("keeps the previous var ambient type for uninitialized redeclarations", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell("var counter = 1;\nreturn counter;"); + session.recordSuccessfulCell("var counter;\nreturn counter;"); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: "counter += 1;\nreturn counter;", + session, + }); + + expect(diagnostics).toEqual([]); + }); + + it("keeps the previous var ambient type for annotated uninitialized redeclarations", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell("var counter = 1;\nreturn counter;"); + session.recordSuccessfulCell('var counter: string;\nreturn "ok";'); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: "counter += 1;\nreturn counter;", + session, + }); + + expect(diagnostics).toEqual([]); + }); + + it("updates var ambient types for annotated redeclarations with same-cell writes", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell("var counter = 1;\nreturn counter;"); + session.recordSuccessfulCell('var counter: string;\ncounter = "ready";\nreturn counter;'); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: "return counter.toUpperCase();", + session, + }); + + expect(diagnostics).toEqual([]); + }); + + it("allows later cells to reference destructured var bindings", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell("var { value } = await Promise.resolve({ value: 3 });"); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: "return value;", + session, + }); + + expect(diagnostics.filter((diagnostic) => diagnostic.severity === "error")).toEqual([]); + }); + + it("allows later cells to reference var bindings from top-level control flow", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell("if (true) { var inner = 7; }"); + session.recordSuccessfulCell("for (var i = 0; i < 3; i += 1) {}"); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: "return inner + i;", + session, + }); + + expect(diagnostics.filter((diagnostic) => diagnostic.severity === "error")).toEqual([]); + }); + + it("falls back to unknown for unresolved or excessively complex var types", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell( + "var unresolved = JSON.parse('{\"value\":1}');\nreturn unresolved;", + ); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: "return unresolved.value;", + session, + }); + + expect(diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "18046", + message: expect.stringContaining("'unresolved' is of type 'unknown'"), + }), + ]), + ); + }); + + it("falls back to unknown for var types that reference cell-local classes", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell("class Local { value = 1; }\nvar saved = new Local();"); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: "return saved.value;", + session, + }); + + expect(diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "18046", + message: expect.stringContaining("'saved' is of type 'unknown'"), + }), + ]), + ); + }); + + it("falls back to unknown for excessively large var types", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell( + `var large = { ${Array.from({ length: 60 }, (_, index) => `p${index}: ${index}`).join(", ")} };`, + ); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: "return large.p1;", + session, + }); + + expect(diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "18046", + message: expect.stringContaining("'large' is of type 'unknown'"), + }), + ]), + ); + }); + + it("does not emit block-scoped let or const bindings into session diagnostics", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell( + "let localLet = 1;\nconst localConst = 2;\nreturn localLet + localConst;", + ); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: "return localLet + localConst;", + session, + }); + + expect(diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "2304", message: expect.stringContaining("localLet") }), + expect.objectContaining({ code: "2304", message: expect.stringContaining("localConst") }), + ]), + ); + }); + + it("uses the latest helper declaration for later diagnostics", () => { + const session = new CodeModeDiagnosticsSession(); + const declaration = "declare const caplets: {};"; + + session.recordSuccessfulCell("function f(x: number): number { return x; }"); + session.recordSuccessfulCell("function f(x: string): string { return x; }"); + const diagnostics = diagnoseCodeModeTypeScript({ + declaration, + code: "const value: number = f('ok');\nreturn value;", + session, + }); + + expect(diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "2322", + message: expect.stringContaining("not assignable to type 'number'"), + }), + ]), + ); + }); +}); diff --git a/packages/core/test/code-mode-sessions.test.ts b/packages/core/test/code-mode-sessions.test.ts new file mode 100644 index 00000000..4763b316 --- /dev/null +++ b/packages/core/test/code-mode-sessions.test.ts @@ -0,0 +1,925 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { CodeModeJournalStore } from "../src/code-mode/journal"; +import { CodeModeSessionManager } from "../src/code-mode/sessions"; +import { runCodeMode } from "../src/code-mode/runner"; +import type { CodeModeReplSession } from "../src/code-mode/sandbox"; +import type { NativeCapletTool, NativeCapletsService } from "../src/native/service"; + +function service(): NativeCapletsService { + const tools: NativeCapletTool[] = [ + { + caplet: "github", + toolName: "caplets__github", + title: "GitHub", + description: "GitHub repo operations.", + promptGuidance: [], + }, + ]; + return { + listTools: () => tools, + execute: vi.fn(async (capletId: string, request: unknown) => ({ + ok: true, + capletId, + request, + })), + reload: vi.fn(async () => true), + onToolsChanged: vi.fn(() => () => undefined), + close: vi.fn(async () => undefined), + }; +} + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe("CodeModeSessionManager", () => { + it("creates and reuses Code Mode state by session id", async () => { + const manager = new CodeModeSessionManager({ idGenerator: () => "session-1" }); + try { + const first = await runCodeMode({ + code: "var counter = 1;\nreturn counter;", + service: service(), + sessionManager: manager, + runtimeScope: "test", + }); + const second = await runCodeMode({ + code: "counter += 1;\nreturn counter;", + service: service(), + sessionManager: manager, + sessionId: "session-1", + runtimeScope: "test", + }); + + expect(first).toMatchObject({ + ok: true, + value: 1, + meta: { sessionId: "session-1", sessionStatus: "created" }, + }); + expect(second).toMatchObject({ + ok: true, + value: 2, + meta: { + sessionId: "session-1", + sessionStatus: "reused", + recoveryRef: null, + }, + }); + } finally { + manager.close(); + } + }); + + it("rejects unknown session ids before invoking Caplets", async () => { + const native = service(); + const manager = new CodeModeSessionManager(); + try { + const result = await runCodeMode({ + code: 'return await caplets.github.callTool("listIssues", {});', + service: native, + sessionManager: manager, + sessionId: "missing", + runtimeScope: "test", + }); + + expect(result).toMatchObject({ + ok: false, + error: { code: "SESSION_NOT_FOUND" }, + meta: { sessionId: "missing", sessionStatus: null }, + }); + expect(native.execute).not.toHaveBeenCalled(); + } finally { + manager.close(); + } + }); + + it("uses prior successful cells for reused-session TypeScript diagnostics", async () => { + const manager = new CodeModeSessionManager({ idGenerator: () => "session-diagnostics" }); + try { + const first = await runCodeMode({ + code: "function helper(): number { return 42; }\nreturn helper();", + service: service(), + sessionManager: manager, + runtimeScope: "test", + }); + const second = await runCodeMode({ + code: "return helper();", + service: service(), + sessionManager: manager, + sessionId: "session-diagnostics", + runtimeScope: "test", + }); + + expect(first).toMatchObject({ ok: true, value: 42 }); + expect(second).toMatchObject({ + ok: true, + value: 42, + meta: { + sessionId: "session-diagnostics", + sessionStatus: "reused", + }, + }); + expect(second.diagnostics.filter((diagnostic) => diagnostic.severity === "error")).toEqual( + [], + ); + } finally { + manager.close(); + } + }); + + it("infers prior helper return types for reused-session diagnostics", async () => { + const manager = new CodeModeSessionManager({ idGenerator: () => "session-helper-inferred" }); + try { + const first = await runCodeMode({ + code: "function helper() { return 42; }\nreturn helper();", + service: service(), + sessionManager: manager, + runtimeScope: "test", + }); + const second = await runCodeMode({ + code: "const n: number = helper();\nreturn n + 1;", + service: service(), + sessionManager: manager, + sessionId: "session-helper-inferred", + runtimeScope: "test", + }); + + expect(first).toMatchObject({ ok: true, value: 42 }); + expect(second).toMatchObject({ ok: true, value: 43, diagnostics: [] }); + } finally { + manager.close(); + } + }); + + it("records generated Caplet handle declarations for persisted session vars", async () => { + const manager = new CodeModeSessionManager({ idGenerator: () => "session-handle-var" }); + try { + const first = await runCodeMode({ + code: "var gh = caplets.github;\nreturn gh.id;", + service: service(), + sessionManager: manager, + runtimeScope: "test", + }); + const second = await runCodeMode({ + code: "return await gh.inspect();", + service: service(), + sessionManager: manager, + sessionId: "session-handle-var", + runtimeScope: "test", + }); + + expect(first).toMatchObject({ ok: true, value: "github" }); + expect(second).toMatchObject({ ok: true, diagnostics: [] }); + } finally { + manager.close(); + } + }); + + it("evicts idle sessions by TTL", async () => { + let now = 0; + const manager = new CodeModeSessionManager({ + idGenerator: () => "session-ttl", + now: () => now, + ttlMs: 10, + }); + try { + await runCodeMode({ + code: "var value = 1;\nreturn value;", + service: service(), + sessionManager: manager, + runtimeScope: "test", + }); + now = 11; + const result = await runCodeMode({ + code: "return value;", + service: service(), + sessionManager: manager, + sessionId: "session-ttl", + runtimeScope: "test", + }); + + expect(result).toMatchObject({ + ok: false, + error: { code: "SESSION_NOT_FOUND" }, + }); + } finally { + manager.close(); + } + }); + + it("reads retained recovery history with the ref from the original run", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-code-mode-session-journal-")); + let now = 0; + const manager = new CodeModeSessionManager({ + idGenerator: () => "session-expired", + now: () => now, + ttlMs: 10, + }); + const journalStore = new CodeModeJournalStore({ + stateDir: dir, + secret: "test-secret", + now: () => new Date("2026-06-17T12:00:00.000Z"), + }); + try { + const first = await runCodeMode({ + code: "function helper() { return 42; }\nreturn helper();", + service: service(), + sessionManager: manager, + journalStore, + runtimeScope: "test", + }); + const nextJournalStore = new CodeModeJournalStore({ + stateDir: dir, + secret: "test-secret", + now: () => new Date("2026-06-17T12:00:01.000Z"), + }); + now = 11; + const expired = await runCodeMode({ + code: "return helper();", + service: service(), + sessionManager: manager, + journalStore: nextJournalStore, + sessionId: "session-expired", + runtimeScope: "test", + }); + + expect(expired).toMatchObject({ + ok: false, + error: { code: "SESSION_NOT_FOUND" }, + meta: { + sessionId: "session-expired", + recoveryRef: first.meta.recoveryRef, + }, + }); + + expect(first).toMatchObject({ + ok: true, + meta: { + recoveryRef: expect.stringMatching(/^[a-f0-9]{48}$/u), + }, + }); + const recoveryRef = first.meta.recoveryRef ?? ""; + const recovered = await nextJournalStore.readRecovery({ recoveryRef }); + expect(recovered.entries).toEqual([ + expect.objectContaining({ + code: expect.stringContaining("function helper"), + recoveryClassification: "setup_like", + }), + ]); + } finally { + manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns retained recovery history for an old session id in a fresh manager", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-code-mode-session-journal-")); + const firstManager = new CodeModeSessionManager({ idGenerator: () => "session-restart" }); + const journalStore = new CodeModeJournalStore({ + stateDir: dir, + secret: "test-secret", + now: () => new Date("2026-06-17T12:00:00.000Z"), + }); + try { + await runCodeMode({ + code: "function helper() { return 42; }\nreturn helper();", + service: service(), + sessionManager: firstManager, + journalStore, + runtimeScope: "test", + }); + firstManager.close(); + const freshManager = new CodeModeSessionManager(); + const freshJournalStore = new CodeModeJournalStore({ + stateDir: dir, + secret: "test-secret", + now: () => new Date("2026-06-17T12:00:01.000Z"), + }); + const result = await runCodeMode({ + code: "return helper();", + service: service(), + sessionManager: freshManager, + journalStore: freshJournalStore, + sessionId: "session-restart", + runtimeScope: "test", + }); + + expect(result).toMatchObject({ + ok: false, + error: { code: "SESSION_NOT_FOUND" }, + meta: { + sessionId: "session-restart", + recoveryRef: expect.stringMatching(/^[a-f0-9]{48}$/u), + }, + }); + freshManager.close(); + } finally { + firstManager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("does not journal diagnostic-only calls for unknown managed session ids", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-code-mode-session-journal-")); + const manager = new CodeModeSessionManager(); + const journalStore = new CodeModeJournalStore({ + stateDir: dir, + secret: "test-secret", + now: () => new Date("2026-06-17T12:00:00.000Z"), + }); + try { + const diagnostic = await runCodeMode({ + code: 'await caplets.github.call("listIssues", {});', + service: service(), + sessionManager: manager, + journalStore, + sessionId: "ghost", + runtimeScope: "test", + }); + const next = await runCodeMode({ + code: "return 1;", + service: service(), + sessionManager: manager, + journalStore, + sessionId: "ghost", + runtimeScope: "test", + }); + + expect(diagnostic).toMatchObject({ + ok: false, + error: { code: "SESSION_NOT_FOUND" }, + meta: { + sessionId: "ghost", + recoveryRef: null, + }, + }); + expect(next).toMatchObject({ + ok: false, + error: { code: "SESSION_NOT_FOUND" }, + meta: { + sessionId: "ghost", + recoveryRef: null, + }, + }); + await expect(journalStore.lookupSession("ghost")).resolves.toBeUndefined(); + } finally { + manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns retained recovery history when the session expires at validation", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-code-mode-session-journal-")); + let now = 0; + const manager = new CodeModeSessionManager({ + idGenerator: () => "session-diagnostic-expired", + ttlMs: 10, + now: () => now, + }); + const journalStore = new CodeModeJournalStore({ + stateDir: dir, + secret: "test-secret", + now: () => new Date("2026-06-17T12:00:00.000Z"), + }); + try { + const first = await runCodeMode({ + code: "var saved = 1;\nreturn saved;", + service: service(), + sessionManager: manager, + journalStore, + runtimeScope: "test", + }); + const nextJournalStore = new CodeModeJournalStore({ + stateDir: dir, + secret: "test-secret", + now: () => new Date("2026-06-17T12:00:01.000Z"), + }); + now = 11; + const diagnostic = await runCodeMode({ + code: 'await caplets.github.call("listIssues", {});', + service: service(), + sessionManager: manager, + journalStore: nextJournalStore, + sessionId: "session-diagnostic-expired", + runtimeScope: "test", + }); + const recovery = await nextJournalStore.readRecovery({ + recoveryRef: first.meta.recoveryRef ?? "", + }); + + expect(diagnostic).toMatchObject({ + ok: false, + error: { code: "SESSION_NOT_FOUND" }, + meta: { + sessionId: "session-diagnostic-expired", + recoveryRef: first.meta.recoveryRef, + }, + }); + expect(recovery.entries).toHaveLength(1); + await expect( + nextJournalStore.lookupSession("session-diagnostic-expired"), + ).resolves.toMatchObject({ recoveryRef: first.meta.recoveryRef }); + } finally { + manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("journals diagnostic-blocked active session cells under the original recovery ref", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-code-mode-session-journal-")); + const manager = new CodeModeSessionManager({ idGenerator: () => "session-diagnostic" }); + const journalStore = new CodeModeJournalStore({ + stateDir: dir, + secret: "test-secret", + now: () => new Date("2026-06-17T12:00:00.000Z"), + }); + try { + const first = await runCodeMode({ + code: "var saved = 1;\nreturn saved;", + service: service(), + sessionManager: manager, + journalStore, + runtimeScope: "test", + }); + const diagnostic = await runCodeMode({ + code: 'await caplets.github.call("listIssues", {});', + service: service(), + sessionManager: manager, + journalStore, + sessionId: "session-diagnostic", + runtimeScope: "test", + }); + const recovery = await journalStore.readRecovery({ + recoveryRef: first.meta.recoveryRef ?? "", + }); + + expect(diagnostic).toMatchObject({ + ok: false, + error: { code: "diagnostic_blocked" }, + meta: { + sessionId: "session-diagnostic", + sessionStatus: "reused", + recoveryRef: null, + }, + }); + expect(recovery.entries).toHaveLength(2); + expect(recovery.entries[0]?.code).toContain("var saved"); + expect(recovery.entries[1]?.outcome).toMatchObject({ ok: false, code: "diagnostic_blocked" }); + } finally { + manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("classifies timer-disposed session cells as unknown recovery history", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-code-mode-session-journal-")); + const manager = new CodeModeSessionManager({ idGenerator: () => "session-timer" }); + const journalStore = new CodeModeJournalStore({ + stateDir: dir, + secret: "test-secret", + now: () => new Date("2026-06-17T12:00:00.000Z"), + }); + try { + const result = await runCodeMode({ + code: 'const timer = setTimeout(() => console.log("later"), 1000);\nreturn 1;', + service: service(), + sessionManager: manager, + journalStore, + runtimeScope: "test", + timeoutMs: 2_000, + }); + const recovery = await journalStore.readRecovery({ + recoveryRef: result.meta.recoveryRef ?? "", + }); + + expect(result).toMatchObject({ ok: true, value: 1 }); + expect(result.meta).toMatchObject({ sessionId: null, sessionStatus: null }); + expect(recovery.entries).toEqual([ + expect.objectContaining({ + recoveryClassification: "unknown", + }), + ]); + expect(manager.has("session-timer")).toBe(false); + } finally { + manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects stale session ids when compatibility changes before executing code", async () => { + const manager = new CodeModeSessionManager({ idGenerator: () => "session-compat" }); + const changedService = service(); + const native = service(); + changedService.listTools = () => [ + { + caplet: "linear", + toolName: "caplets__linear", + title: "Linear", + description: "Linear operations.", + promptGuidance: [], + }, + ]; + try { + await runCodeMode({ + code: "var value = 1;\nreturn value;", + service: native, + sessionManager: manager, + runtimeScope: "test", + }); + const result = await runCodeMode({ + code: "return await caplets.linear.callTool('sideEffect', {});", + service: changedService, + sessionManager: manager, + sessionId: "session-compat", + runtimeScope: "test", + }); + + expect(result).toMatchObject({ + ok: false, + error: { code: "SESSION_NOT_FOUND" }, + meta: { sessionId: "session-compat", sessionStatus: null }, + }); + expect(changedService.execute).not.toHaveBeenCalled(); + } finally { + manager.close(); + } + }); + + it("returns retained recovery history when compatibility changes", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-code-mode-session-journal-")); + const manager = new CodeModeSessionManager({ idGenerator: () => "session-compat-journal" }); + const journalStore = new CodeModeJournalStore({ + stateDir: dir, + secret: "test-secret", + now: () => new Date("2026-06-17T12:00:00.000Z"), + }); + const changedService = service(); + changedService.listTools = () => [ + { + caplet: "linear", + toolName: "caplets__linear", + title: "Linear", + description: "Linear operations.", + promptGuidance: [], + }, + ]; + try { + const first = await runCodeMode({ + code: "var oldValue = 1;\nreturn oldValue;", + service: service(), + sessionManager: manager, + journalStore, + runtimeScope: "test", + }); + const nextJournalStore = new CodeModeJournalStore({ + stateDir: dir, + secret: "test-secret", + now: () => new Date("2026-06-17T12:00:01.000Z"), + }); + const second = await runCodeMode({ + code: "var newValue = 2;\nreturn typeof oldValue;", + service: changedService, + sessionManager: manager, + journalStore: nextJournalStore, + sessionId: "session-compat-journal", + runtimeScope: "test", + }); + + const oldRecovery = await nextJournalStore.readRecovery({ + recoveryRef: first.meta.recoveryRef ?? "", + }); + + expect(first).toMatchObject({ ok: true, value: 1 }); + expect(second).toMatchObject({ + ok: false, + error: { code: "SESSION_NOT_FOUND" }, + meta: { + sessionStatus: null, + recoveryRef: first.meta.recoveryRef, + }, + }); + expect(oldRecovery.entries).toHaveLength(1); + expect(oldRecovery.entries[0]?.code).toContain("var oldValue = 1"); + expect(oldRecovery.entries[0]?.code).not.toContain("newValue"); + } finally { + manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns a busy error for overlapping runs on the same session", async () => { + const manager = new CodeModeSessionManager({ idGenerator: () => "session-busy" }); + try { + await runCodeMode({ + code: "var value = 1;\nreturn value;", + service: service(), + sessionManager: manager, + runtimeScope: "test", + }); + const slow = runCodeMode({ + code: "await new Promise((resolve) => setTimeout(resolve, 50));\nreturn value;", + service: service(), + sessionManager: manager, + sessionId: "session-busy", + runtimeScope: "test", + timeoutMs: 1_000, + }); + const busy = await runCodeMode({ + code: "return value;", + service: service(), + sessionManager: manager, + sessionId: "session-busy", + runtimeScope: "test", + }); + + expect(busy).toMatchObject({ + ok: false, + error: { code: "SESSION_BUSY" }, + }); + await slow; + } finally { + manager.close(); + } + }); + + it("returns busy before diagnostics for overlapping cells that reference new state", async () => { + const manager = new CodeModeSessionManager({ idGenerator: () => "session-busy-diagnostics" }); + try { + await runCodeMode({ + code: "var value = 1;\nreturn value;", + service: service(), + sessionManager: manager, + runtimeScope: "test", + }); + const slow = runCodeMode({ + code: "await new Promise((resolve) => setTimeout(resolve, 50));\nfunction activeHelper() { return value; }\nreturn activeHelper();", + service: service(), + sessionManager: manager, + sessionId: "session-busy-diagnostics", + runtimeScope: "test", + timeoutMs: 1_000, + }); + const busy = await runCodeMode({ + code: "return activeHelper();", + service: service(), + sessionManager: manager, + sessionId: "session-busy-diagnostics", + runtimeScope: "test", + }); + + expect(busy).toMatchObject({ + ok: false, + error: { code: "SESSION_BUSY" }, + diagnostics: [], + }); + await slow; + } finally { + manager.close(); + } + }); + + it("does not invoke downstream Caplets after close", async () => { + const manager = new CodeModeSessionManager({ idGenerator: () => "session-close" }); + const native = service(); + try { + const run = runCodeMode({ + code: 'await new Promise((resolve) => setTimeout(resolve, 80));\nreturn await caplets.github.callTool("afterClose", {});', + service: native, + sessionManager: manager, + runtimeScope: "test", + timeoutMs: 1_000, + }); + await delay(20); + manager.close(); + const result = await run; + + expect(result).toMatchObject({ + ok: false, + error: { message: "Code Mode session manager is closed." }, + }); + expect(native.execute).not.toHaveBeenCalled(); + } finally { + manager.close(); + } + }); + + it("disposes a session created after close and returns a closed error", async () => { + const createStarted = deferred(); + const releaseCreate = deferred(); + const sessionRun = vi.fn(async () => ({ + ok: true as const, + value: "ran-after-close", + logs: [], + })); + const sessionDispose = vi.fn(); + const fakeSession: CodeModeReplSession = { + run: sessionRun, + dispose: sessionDispose, + isDisposed: () => sessionDispose.mock.calls.length > 0, + }; + const manager = new CodeModeSessionManager({ + idGenerator: () => "session-race", + sandboxFactory: () => ({ + createSession: async () => { + createStarted.resolve(); + await releaseCreate.promise; + return fakeSession; + }, + }), + }); + + const run = runCodeMode({ + code: 'return "ran-after-close";', + service: service(), + sessionManager: manager, + runtimeScope: "test", + }); + await createStarted.promise; + manager.close(); + releaseCreate.resolve(); + const result = await run; + + expect(result).toMatchObject({ + ok: false, + error: { code: "SESSION_CLOSED" }, + meta: { sessionId: "session-race", sessionStatus: null }, + }); + expect(sessionDispose).toHaveBeenCalledOnce(); + expect(sessionRun).not.toHaveBeenCalled(); + expect(manager.has("session-race")).toBe(false); + }); + + it("enforces maxSessions after overlapping fresh runs become idle", async () => { + let nextId = 0; + const manager = new CodeModeSessionManager({ + maxSessions: 1, + idGenerator: () => `session-${++nextId}`, + }); + try { + const first = runCodeMode({ + code: "await new Promise((resolve) => setTimeout(resolve, 80));\nreturn 1;", + service: service(), + sessionManager: manager, + runtimeScope: "test", + timeoutMs: 1_000, + }); + await delay(20); + const second = runCodeMode({ + code: "await new Promise((resolve) => setTimeout(resolve, 80));\nreturn 2;", + service: service(), + sessionManager: manager, + runtimeScope: "test", + timeoutMs: 1_000, + }); + + await Promise.all([first, second]); + + expect([manager.has("session-1"), manager.has("session-2")].filter(Boolean)).toHaveLength(1); + } finally { + manager.close(); + } + }); + + it("forgets sessions that dispose themselves after a run", async () => { + const manager = new CodeModeSessionManager({ idGenerator: () => "session-disposed" }); + try { + await runCodeMode({ + code: "var x = 'persisted';\nreturn x;", + service: service(), + sessionManager: manager, + runtimeScope: "test", + }); + const tainted = await runCodeMode({ + code: "__caplets_persist.x = 'poisoned';\nreturn x;", + service: service(), + sessionManager: manager, + sessionId: "session-disposed", + runtimeScope: "test", + }); + const next = await runCodeMode({ + code: "return x;", + service: service(), + sessionManager: manager, + sessionId: "session-disposed", + runtimeScope: "test", + }); + + expect(tainted).toMatchObject({ ok: true, value: "persisted" }); + expect(next).toMatchObject({ + ok: false, + error: { code: "SESSION_NOT_FOUND" }, + }); + } finally { + manager.close(); + } + }); + + it("uses prior successful cells for reused session diagnostics", async () => { + const manager = new CodeModeSessionManager({ idGenerator: () => "session-diagnostics-reuse" }); + try { + const first = await runCodeMode({ + code: "function helper() { return 42; }\nreturn helper();", + service: service(), + sessionManager: manager, + runtimeScope: "test", + }); + const rejected = await runCodeMode({ + code: 'await caplets.github.call("listIssues", {});', + service: service(), + sessionManager: manager, + sessionId: "session-diagnostics-reuse", + runtimeScope: "test", + }); + const second = await runCodeMode({ + code: "return helper();", + service: service(), + sessionManager: manager, + sessionId: "session-diagnostics-reuse", + runtimeScope: "test", + }); + + expect(first).toMatchObject({ ok: true, value: 42 }); + expect(rejected).toMatchObject({ ok: false, error: { code: "diagnostic_blocked" } }); + expect(second).toMatchObject({ ok: true, value: 42 }); + expect(second.diagnostics).toEqual([]); + } finally { + manager.close(); + } + }); + + it("does not record failed diagnostic cells into inferred session var types", async () => { + const manager = new CodeModeSessionManager({ idGenerator: () => "session-var-diagnostics" }); + try { + const first = await runCodeMode({ + code: "var counter = 1;\nreturn counter;", + service: service(), + sessionManager: manager, + runtimeScope: "test", + }); + const rejected = await runCodeMode({ + code: 'await caplets.github.call("listIssues", {});\nvar counter = "bad";', + service: service(), + sessionManager: manager, + sessionId: "session-var-diagnostics", + runtimeScope: "test", + }); + const reused = await runCodeMode({ + code: "counter += 1;\nreturn counter;", + service: service(), + sessionManager: manager, + sessionId: "session-var-diagnostics", + runtimeScope: "test", + }); + + expect(first).toMatchObject({ ok: true, value: 1 }); + expect(rejected).toMatchObject({ ok: false, error: { code: "diagnostic_blocked" } }); + expect(reused).toMatchObject({ ok: true, value: 2 }); + expect(reused.diagnostics).toEqual([]); + } finally { + manager.close(); + } + }); + + it("updates inferred session var types after object destructuring assignment", async () => { + const manager = new CodeModeSessionManager({ idGenerator: () => "session-destructuring" }); + try { + const first = await runCodeMode({ + code: "var counter = 1;\nreturn counter;", + service: service(), + sessionManager: manager, + runtimeScope: "test", + }); + const assigned = await runCodeMode({ + code: '({ next: counter } = { next: "ready" });\nreturn counter;', + service: service(), + sessionManager: manager, + sessionId: "session-destructuring", + runtimeScope: "test", + }); + const reused = await runCodeMode({ + code: "return counter.toUpperCase();", + service: service(), + sessionManager: manager, + sessionId: "session-destructuring", + runtimeScope: "test", + }); + + expect(first).toMatchObject({ ok: true, value: 1 }); + expect(assigned).toMatchObject({ ok: true, value: "ready" }); + expect(reused).toMatchObject({ ok: true, value: "READY" }); + expect(reused.diagnostics.filter((diagnostic) => diagnostic.severity === "error")).toEqual( + [], + ); + } finally { + manager.close(); + } + }); +}); diff --git a/packages/core/test/native-options.test.ts b/packages/core/test/native-options.test.ts index 0fb84979..a55c68fd 100644 --- a/packages/core/test/native-options.test.ts +++ b/packages/core/test/native-options.test.ts @@ -88,7 +88,9 @@ describe("resolveNativeCapletsServiceOptions", () => { it("does not treat server hosting env vars as native remote client settings", () => { expect( - resolveNativeCapletsServiceOptions({}, { CAPLETS_SERVER_URL: "http://127.0.0.1:5387" }), + resolveNativeCapletsServiceOptions({}, { + CAPLETS_SERVER_URL: "http://127.0.0.1:5387", + } as Record), ).toEqual({ mode: "local" }); }); @@ -100,14 +102,14 @@ describe("resolveNativeCapletsServiceOptions", () => { it("rejects non-loopback http URLs", () => { expect(() => - resolveNativeCapletsServiceOptions({ server: { url: "http://caplets.example.com" } }, {}), + resolveNativeCapletsServiceOptions({ remote: { url: "http://caplets.example.com" } }, {}), ).toThrow(/https/u); }); it("does not echo invalid credential-bearing remote URL inputs", () => { const rawUrl = "https://caplets:secret@exa mple.com/mcp"; - expect(() => resolveNativeCapletsServiceOptions({ server: { url: rawUrl } }, {})).toThrowError( + expect(() => resolveNativeCapletsServiceOptions({ remote: { url: rawUrl } }, {})).toThrowError( expect.not.stringContaining(rawUrl), ); }); @@ -117,7 +119,7 @@ describe("resolveNativeCapletsServiceOptions", () => { "https://caplets:secret@caplets.example.com", "http://caplets:secret@127.0.0.1:5387", ]) { - expect(() => resolveNativeCapletsServiceOptions({ server: { url } }, {})).toThrow( + expect(() => resolveNativeCapletsServiceOptions({ remote: { url } }, {})).toThrow( /must not include username, password, query string, or fragment/u, ); } @@ -128,7 +130,7 @@ describe("resolveNativeCapletsServiceOptions", () => { expect( resolveNativeCapletsServiceOptions( { - server: { + remote: { url: "https://configured.example.com/caplets", user: "configured", password: configPassword, @@ -153,7 +155,7 @@ describe("resolveNativeCapletsServiceOptions", () => { const password = ["remote", "password"].join("-"); expect( resolveNativeCapletsServiceOptions( - { server: { url: "https://caplets.example.com", password } }, + { remote: { url: "https://caplets.example.com", password } }, {}, ), ).toMatchObject({ @@ -164,7 +166,7 @@ describe("resolveNativeCapletsServiceOptions", () => { it("rejects user without password", () => { expect(() => resolveNativeCapletsServiceOptions( - { server: { url: "https://caplets.example.com", user: "caplets" } }, + { remote: { url: "https://caplets.example.com", user: "caplets" } }, {}, ), ).toThrow(/requires a password/u); @@ -176,8 +178,6 @@ describe("resolveNativeCapletsServiceOptions", () => { { remote: { pollIntervalMs: 5_000, - }, - server: { url: "https://caplets.example.com/caplets", user: "caplets", password, @@ -198,14 +198,14 @@ describe("resolveNativeCapletsServiceOptions", () => { it("rejects invalid poll intervals", () => { expect(() => resolveNativeCapletsServiceOptions( - { server: { url: "https://caplets.example.com" }, remote: { pollIntervalMs: 999 } }, + { remote: { url: "https://caplets.example.com", pollIntervalMs: 999 } }, {}, ), ).toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); expect(() => resolveNativeCapletsServiceOptions( - { server: { url: "https://caplets.example.com" }, remote: { pollIntervalMs: 1_000.5 } }, + { remote: { url: "https://caplets.example.com", pollIntervalMs: 1_000.5 } }, {}, ), ).toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); diff --git a/packages/core/test/native-remote.test.ts b/packages/core/test/native-remote.test.ts index 63017fef..dfda869d 100644 --- a/packages/core/test/native-remote.test.ts +++ b/packages/core/test/native-remote.test.ts @@ -367,6 +367,36 @@ describe("RemoteNativeCapletsService", () => { await remote.close(); }); + it("loads older attach manifests without Code Mode caplet entries", async () => { + const remote = createSdkRemoteCapletsClient({ + url: new URL("https://caplets.example.com/v1/attach"), + requestInit: {}, + fetch: vi.fn(async (input) => { + if (String(input).endsWith("/manifest")) { + const { codeModeCaplets: _codeModeCaplets, ...manifest } = attachManifest( + "rev-1", + "export-caplet", + ); + return Response.json(manifest); + } + return Response.json({ ok: true, data: { invoked: true } }); + }), + auth: { enabled: false, user: "caplets" }, + pollIntervalMs: 60_000, + }); + + await expect(remote.listTools()).resolves.toEqual([ + expect.objectContaining({ + name: "remote", + codeModeCaplets: [], + }), + ]); + await expect(remote.callTool("remote", { operation: "inspect" })).resolves.toEqual({ + invoked: true, + }); + await remote.close(); + }); + it("notifies listeners when the attach events stream reports a manifest change", async () => { let eventController: ReadableStreamDefaultController | undefined; const encoder = new TextEncoder(); @@ -969,9 +999,20 @@ describe("RemoteNativeCapletsService", () => { expect.objectContaining({ caplet: "code_mode", codeModeRun: true, + description: expect.stringContaining("`meta.sessionId`"), + promptGuidance: expect.arrayContaining([ + expect.stringContaining("omit sessionId to start fresh"), + expect.stringContaining("returned meta.sessionId"), + expect.stringContaining("meta.recoveryRef"), + ]), inputSchema: expect.objectContaining({ required: ["code"], - properties: expect.objectContaining({ code: expect.any(Object) }), + properties: expect.objectContaining({ + code: expect.any(Object), + sessionId: expect.objectContaining({ + description: expect.stringContaining("Omit to create a fresh reusable session"), + }), + }), }), }), ); @@ -1312,7 +1353,7 @@ describe("createNativeCapletsService remote mode", () => { const fixture = client(); const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory: vi.fn(() => fixture.api), }); @@ -1330,7 +1371,7 @@ describe("createNativeCapletsService remote mode", () => { expect(() => createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory, localServiceFactory, }), @@ -1357,7 +1398,7 @@ describe("createNativeCapletsService remote mode", () => { expect(() => createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, localServiceFactory: vi.fn(() => localService), remoteClientFactory, }), @@ -1386,7 +1427,7 @@ describe("createNativeCapletsService remote mode", () => { expect(() => createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, localServiceFactory: vi.fn(() => localService), remoteClientFactory, writeErr, @@ -1414,7 +1455,7 @@ describe("createNativeCapletsService remote mode", () => { expect(() => createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, localServiceFactory: vi.fn(() => localService), remoteClientFactory: vi.fn(() => { throw new Error("Project Binding unavailable"); @@ -1435,7 +1476,7 @@ describe("createNativeCapletsService remote mode", () => { dirs.push(dir); const service = createNativeCapletsService({ - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory: vi.fn(() => { throw new Error("Project Binding unavailable"); }), @@ -1444,7 +1485,7 @@ describe("createNativeCapletsService remote mode", () => { writeErr, }); const secondService = createNativeCapletsService({ - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory: vi.fn(() => { throw new Error("Project Binding unavailable"); }), @@ -1477,7 +1518,7 @@ describe("createNativeCapletsService remote mode", () => { const writeErr = vi.fn(); const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory: vi.fn(() => fixture.api), configPath, projectConfigPath, @@ -1511,7 +1552,7 @@ describe("createNativeCapletsService remote mode", () => { const writeErr = vi.fn(); const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory: vi.fn(() => fixture.api), configPath, projectConfigPath, @@ -1556,7 +1597,7 @@ describe("createNativeCapletsService remote mode", () => { }; const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory: vi.fn(() => fixture.api), localServiceFactory: vi.fn(() => localService), writeErr, @@ -1604,7 +1645,7 @@ describe("createNativeCapletsService remote mode", () => { } satisfies NativeCapletsService; const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory: vi.fn(() => fixture.api), localServiceFactory: vi.fn(() => localService), }); @@ -1686,7 +1727,7 @@ describe("createNativeCapletsService remote mode", () => { const writeErr = vi.fn(); const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory: vi.fn(() => fixture.api), configPath, projectConfigPath, @@ -1736,7 +1777,7 @@ describe("createNativeCapletsService remote mode", () => { } satisfies NativeCapletsService; const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory: vi.fn(() => fixture.api), localServiceFactory: vi.fn(() => localService), }); @@ -1761,7 +1802,7 @@ describe("createNativeCapletsService remote mode", () => { dirs.push(dir); const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory: vi.fn(() => fixture.api), configPath, projectConfigPath, @@ -1774,6 +1815,18 @@ describe("createNativeCapletsService remote mode", () => { expect(tools.find((tool) => tool.caplet === "code_mode")).toEqual( expect.objectContaining({ codeModeRun: true, + description: expect.stringContaining("`meta.sessionId`"), + promptGuidance: expect.arrayContaining([ + expect.stringContaining("omit sessionId to start fresh"), + ]), + inputSchema: expect.objectContaining({ + properties: expect.objectContaining({ + sessionId: expect.objectContaining({ + type: "string", + description: expect.stringContaining("Unknown or unavailable session IDs fail"), + }), + }), + }), codeModeCaplets: expect.arrayContaining([ expect.objectContaining({ id: "remote-only" }), expect.objectContaining({ id: "local" }), @@ -1815,7 +1868,7 @@ describe("createNativeCapletsService remote mode", () => { } satisfies NativeCapletsService; const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory: vi.fn(() => fixture.api), localServiceFactory: vi.fn(() => localService), }); @@ -1852,7 +1905,7 @@ describe("createNativeCapletsService remote mode", () => { dirs.push(dir); const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory: vi.fn(() => fixture.api), configPath, projectConfigPath, @@ -1875,7 +1928,7 @@ describe("createNativeCapletsService remote mode", () => { dirs.push(dir); const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory: vi.fn(() => fixture.api), configPath, projectConfigPath, @@ -1909,7 +1962,7 @@ describe("createNativeCapletsService remote mode", () => { }; const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory: vi.fn(() => fixture.api), localServiceFactory: vi.fn(() => localService), }); @@ -1922,6 +1975,14 @@ describe("createNativeCapletsService remote mode", () => { }, diagnostics: [], }); + const result = (await service.execute("code_mode", { timeoutMs: 1_000 })) as { + meta: Record; + }; + expect(result.meta).toMatchObject({ + sessionId: null, + sessionStatus: null, + recoveryRef: null, + }); expect(localExecute).not.toHaveBeenCalled(); expect(fixture.api.callTool).not.toHaveBeenCalled(); await service.close(); @@ -1952,7 +2013,7 @@ describe("createNativeCapletsService remote mode", () => { dirs.push(dir); const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory: vi.fn(() => fixture.api), configPath, projectConfigPath, @@ -1961,15 +2022,25 @@ describe("createNativeCapletsService remote mode", () => { try { await service.reload(); + const first = (await service.execute("code_mode", { + code: "var counter = 1;\nreturn { keys: Object.keys(caplets).sort(), counter };", + })) as { meta: { sessionId: string } }; await expect( service.execute("code_mode", { - code: "return { keys: Object.keys(caplets).sort() };", + code: "counter += 1;\nreturn { keys: Object.keys(caplets).sort(), counter };", + sessionId: first.meta.sessionId, }), ).resolves.toMatchObject({ ok: true, value: { + counter: 2, keys: ["debug", "local-code", "remote-only"], }, + meta: { + sessionId: first.meta.sessionId, + sessionStatus: "reused", + recoveryRef: null, + }, }); } finally { await service.close(); @@ -2005,7 +2076,7 @@ describe("createNativeCapletsService remote mode", () => { }; const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387", fetch: fetchFromApp }, + remote: { url: "http://127.0.0.1:5387", fetch: fetchFromApp }, configPath: localConfig.configPath, projectConfigPath: localConfig.projectConfigPath, }); @@ -2040,7 +2111,7 @@ describe("createNativeCapletsService remote mode", () => { dirs.push(dir); const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory: vi.fn(() => fixture.api), configPath, projectConfigPath, @@ -2069,7 +2140,7 @@ describe("createNativeCapletsService remote mode", () => { dirs.push(dir); const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory: vi.fn(() => fixture.api), configPath, projectConfigPath, @@ -2115,7 +2186,7 @@ describe("createNativeCapletsService remote mode", () => { dirs.push(dir); const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory: vi.fn(() => fixture.api), configPath, projectConfigPath, @@ -2149,7 +2220,7 @@ describe("createNativeCapletsService remote mode", () => { dirs.push(dir); const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory: vi.fn(() => fixture.api), configPath, projectConfigPath, @@ -2188,7 +2259,7 @@ describe("createNativeCapletsService remote mode", () => { dirs.push(dir); const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory: vi.fn(() => fixture.api), configPath, projectConfigPath, @@ -2213,7 +2284,7 @@ describe("createNativeCapletsService remote mode", () => { const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory: vi.fn(() => fixture.api), configPath, projectConfigPath, @@ -2241,7 +2312,7 @@ describe("createNativeCapletsService remote mode", () => { const service = createNativeCapletsService({ mode: "cloud", - server: { url: "https://cloud.caplets.dev/v1/ws/personal/mcp" }, + remote: { url: "https://cloud.caplets.dev/v1/ws/personal/mcp" }, remoteClientFactory: factory, configPath, projectConfigPath, @@ -2271,7 +2342,7 @@ describe("createNativeCapletsService remote mode", () => { ); const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory: vi.fn(() => fixture.api), configPath, projectConfigPath, @@ -2312,11 +2383,10 @@ describe("createNativeCapletsService remote mode", () => { dirs.push(dir); const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387", pollIntervalMs: 1_000 }, remoteClientFactory: vi.fn(() => fixture.api), configPath, projectConfigPath, - remote: { pollIntervalMs: 1_000 }, }); await service.close(); @@ -2355,8 +2425,9 @@ describe("createNativeCapletsService remote mode", () => { const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387", fetch }, remote: { + url: "http://127.0.0.1:5387", + fetch, cloud: { url: "https://cloud.caplets.dev", accessToken: "token", @@ -2411,8 +2482,9 @@ describe("createNativeCapletsService remote mode", () => { dirs.push(dir); const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387", fetch }, remote: { + url: "http://127.0.0.1:5387", + fetch, cloud: { url: "https://cloud.caplets.dev", accessToken: "token", @@ -2446,7 +2518,7 @@ describe("createNativeCapletsService remote mode", () => { it("fails fast for invalid remote config", () => { expect(() => - createNativeCapletsService({ mode: "remote", server: { url: "http://example.com" } }), + createNativeCapletsService({ mode: "remote", remote: { url: "http://example.com" } }), ).toThrow(/https/u); }); }); diff --git a/packages/core/test/native.test.ts b/packages/core/test/native.test.ts index 62003aca..124a9c5a 100644 --- a/packages/core/test/native.test.ts +++ b/packages/core/test/native.test.ts @@ -52,7 +52,8 @@ describe("native Caplets service", () => { const service = createNativeCapletsService({ configPath, projectConfigPath }); try { - expect(service.listTools()).toEqual( + const tools = service.listTools(); + expect(tools).toEqual( expect.arrayContaining([ expect.objectContaining({ caplet: "git-hub", @@ -63,10 +64,35 @@ describe("native Caplets service", () => { caplet: "code_mode", toolName: "caplets__code_mode", title: "Code Mode", + inputSchema: expect.objectContaining({ + properties: expect.objectContaining({ + sessionId: expect.objectContaining({ type: "string" }), + }), + }), }), ]), ); - const githubTool = service.listTools().find((tool) => tool.caplet === "git-hub"); + const codeModeTool = tools.find((tool) => tool.caplet === "code_mode"); + expect(codeModeTool?.description).toContain("`meta.sessionId`"); + expect(codeModeTool?.description).toContain("fails before executing your code"); + expect(codeModeTool?.promptGuidance).toEqual( + expect.arrayContaining([ + expect.stringContaining("omit sessionId to start fresh"), + expect.stringContaining("returned meta.sessionId"), + expect.stringContaining("meta.recoveryRef"), + ]), + ); + expect( + ( + codeModeTool?.inputSchema as { + properties?: { sessionId?: { description?: string } }; + } + )?.properties?.sessionId?.description, + ).toContain("Omit to create a fresh reusable session"); + expect(nativeCapletsSystemGuidance(["caplets__code_mode"])).toContain( + "omit sessionId to start fresh", + ); + const githubTool = tools.find((tool) => tool.caplet === "git-hub"); expect(githubTool?.description).toContain("Native tool name: caplets__git-hub"); expect(githubTool?.inputSchema).toMatchObject({ properties: expect.objectContaining({ fields: expect.anything() }), @@ -362,6 +388,25 @@ describe("native Caplets service", () => { expect(result).toMatchObject({ ok: true, value: { id: "status", hasStatus: true }, + meta: { + sessionId: expect.any(String), + sessionStatus: "created", + recoveryRef: expect.stringMatching(/^[a-f0-9]{48}$/u), + }, + }); + await expect( + service.execute("code_mode", { + code: "return { ok: true };", + sessionId: "session-123", + }), + ).resolves.toMatchObject({ + ok: false, + error: { code: "SESSION_NOT_FOUND" }, + meta: { + sessionId: "session-123", + sessionStatus: null, + recoveryRef: null, + }, }); } finally { await service.close(); @@ -393,6 +438,14 @@ describe("native Caplets service", () => { }, diagnostics: [], }); + const result = (await service.execute("code_mode", { timeoutMs: 1_000 })) as { + meta: Record; + }; + expect(result.meta).toMatchObject({ + sessionId: null, + sessionStatus: null, + recoveryRef: null, + }); } finally { await service.close(); } diff --git a/packages/core/test/project-binding-integration.test.ts b/packages/core/test/project-binding-integration.test.ts index 9e203a75..2aceec84 100644 --- a/packages/core/test/project-binding-integration.test.ts +++ b/packages/core/test/project-binding-integration.test.ts @@ -88,7 +88,7 @@ describe("Project Binding integration", () => { ]); const service = createNativeCapletsService({ mode: "remote", - server: { url: "http://127.0.0.1:5387" }, + remote: { url: "http://127.0.0.1:5387" }, remoteClientFactory: vi.fn(() => remoteClient), configPath, projectConfigPath, diff --git a/packages/core/test/remote-options.test.ts b/packages/core/test/remote-options.test.ts index 9389dbc3..bc24b11b 100644 --- a/packages/core/test/remote-options.test.ts +++ b/packages/core/test/remote-options.test.ts @@ -20,9 +20,12 @@ describe("resolveRemoteMode", () => { }); it("does not treat CAPLETS_SERVER_URL as client remote configuration", () => { - expect(resolveRemoteMode({}, { CAPLETS_SERVER_URL: "https://example.com/caplets" })).toEqual({ - mode: "local", - }); + expect( + resolveRemoteMode({}, { CAPLETS_SERVER_URL: "https://example.com/caplets" } as Record< + string, + string + >), + ).toEqual({ mode: "local" }); }); it("requires CAPLETS_REMOTE_URL in explicit remote mode", () => { diff --git a/packages/opencode/README.md b/packages/opencode/README.md index a7fad2d4..cb826ef8 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -8,6 +8,8 @@ MCP-backed Caplets advertise resource, prompt, template, and completion operatio Use `caplets__code_mode` for multi-step workflows that benefit from Code Mode: TypeScript with generated `caplets.` handles, progressive discovery, downstream tool calls, filtering, joins, and compact synthesis in one native OpenCode call. +For adjacent workflows, omit top-level `sessionId` to start a fresh reusable Code Mode session, then pass the returned `meta.sessionId` as top-level `sessionId` on later calls that should reuse live helper functions, `var` bindings, and runtime state while the session remains available. Unknown or expired sessions fail before execution; use recovery metadata for audit or manual reconstruction rather than automatic replay. + ```jsonc { "plugin": ["@caplets/opencode"], @@ -48,11 +50,9 @@ export default { "@caplets/opencode", { mode: "remote", - server: { + remote: { url: "https://caplets.example.com/caplets", user: "caplets", - }, - remote: { pollIntervalMs: 5_000, }, }, @@ -61,4 +61,4 @@ export default { }; ``` -Plugin config overrides environment variables. The explicit config shape is `{ mode, server: { url, user }, remote: { pollIntervalMs } }`. Prefer `CAPLETS_REMOTE_TOKEN` or `CAPLETS_REMOTE_PASSWORD` for self-hosted credentials unless your OpenCode setup provides secure secret storage. +Plugin config overrides environment variables. The explicit config shape is `{ mode, remote: { url, user, pollIntervalMs } }`. Prefer `CAPLETS_REMOTE_TOKEN` or `CAPLETS_REMOTE_PASSWORD` for self-hosted credentials unless your OpenCode setup provides secure secret storage. diff --git a/packages/opencode/src/hooks.ts b/packages/opencode/src/hooks.ts index 89621aac..4ae89f02 100644 --- a/packages/opencode/src/hooks.ts +++ b/packages/opencode/src/hooks.ts @@ -15,14 +15,19 @@ export async function createCapletsOpenCodeHooks(service: NativeCapletsService): capletTools.map((caplet) => [ caplet.toolName, tool({ - description: caplet.description, + description: caplet.codeModeRun + ? openCodeCodeModeDescription(caplet.description) + : caplet.description, args: caplet.codeModeRun ? capletsOpenCodeRunArgs() : caplet.operationNames ? capletsOpenCodeArgs(caplet.operationNames) : capletsOpenCodeJsonSchemaArgs(caplet.inputSchema), async execute(args) { - const result = await service.execute(caplet.caplet, args); + const result = await service.execute( + caplet.caplet, + caplet.codeModeRun ? normalizeCodeModeRunArgs(args) : args, + ); return compactOpenCodeResult(result); }, }), @@ -41,6 +46,24 @@ export async function createCapletsOpenCodeHooks(service: NativeCapletsService): }; } +function openCodeCodeModeDescription(description: string): string { + return [ + description, + "", + "OpenCode argument shape: omit top-level `sessionId` to start a fresh reusable session. To reuse a live session, pass top-level `sessionId: meta.sessionId`.", + ].join("\n"); +} + +function normalizeCodeModeRunArgs(args: unknown): unknown { + if (!args || typeof args !== "object" || Array.isArray(args)) return args; + const record = args as Record; + const { sessionId: _sessionId, ...rest } = record; + if (typeof record.sessionId === "string" && record.sessionId.trim() !== "") { + return { ...rest, sessionId: record.sessionId }; + } + return rest; +} + function compactOpenCodeResult(result: unknown): string { if (typeof result === "string") return result; if (!result || typeof result !== "object" || Array.isArray(result)) { diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 42d2256c..013fc3ab 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -6,7 +6,7 @@ import { } from "@caplets/core/native"; import { createCapletsOpenCodeHooks } from "./hooks"; -export type CapletsOpenCodeConfig = Pick; +export type CapletsOpenCodeConfig = Pick; const plugin = (async (_ctx: PluginInput, config?: CapletsOpenCodeConfig) => { const service = createNativeCapletsService( @@ -25,7 +25,6 @@ function normalizeOpenCodeConfig(config: CapletsOpenCodeConfig | undefined): Cap } return { ...(config.mode ? { mode: config.mode } : {}), - ...(config.server ? { server: config.server } : {}), ...(config.remote ? { remote: config.remote } : {}), }; } diff --git a/packages/opencode/src/schema.ts b/packages/opencode/src/schema.ts index 8c098592..180ea3a6 100644 --- a/packages/opencode/src/schema.ts +++ b/packages/opencode/src/schema.ts @@ -36,6 +36,7 @@ export function capletsOpenCodeRunArgs() { return { code: tool.schema.string(), timeoutMs: tool.schema.number().int().positive().optional(), + sessionId: tool.schema.string().optional(), }; } diff --git a/packages/opencode/test/opencode.test.ts b/packages/opencode/test/opencode.test.ts index 02def3bb..f16c0597 100644 --- a/packages/opencode/test/opencode.test.ts +++ b/packages/opencode/test/opencode.test.ts @@ -52,12 +52,36 @@ describe("@caplets/opencode", () => { caplet: "code_mode", toolName: "caplets__code_mode", title: "Code Mode", - description: "Run Caplets Code Mode TypeScript.", + description: + "Run Caplets Code Mode TypeScript. Omit sessionId to start fresh and pass returned meta.sessionId to reuse live state.", codeModeRun: true, - promptGuidance: ["Use caplets__code_mode for multi-step Caplets workflows."], + promptGuidance: [ + "Use caplets__code_mode for multi-step Caplets workflows.", + "For REPL reuse, omit sessionId to start fresh, then pass the returned meta.sessionId on later calls that should reuse live state.", + ], }, ], - execute: vi.fn(async () => ({ ok: true })), + execute: vi.fn(async (caplet: string) => + caplet === "code_mode" + ? { + ok: true, + value: { ok: true }, + diagnostics: [], + logs: { entries: [], truncated: false, stored: false }, + meta: { + runId: "run-1", + traceId: "trace-1", + declarationHash: "hash-1", + sessionId: "session-1", + sessionStatus: "created", + recoveryRef: "recovery-1", + timeoutMs: 10000, + maxTimeoutMs: 10000, + durationMs: 25, + }, + } + : { ok: true }, + ), reload: vi.fn(async () => true), onToolsChanged: vi.fn(() => () => {}), close: vi.fn(async () => {}), @@ -74,16 +98,44 @@ describe("@caplets/opencode", () => { expect(result).toContain('"ok": true'); const runTool = hooks.tool!.caplets__code_mode as { - args: { code?: unknown; timeoutMs?: unknown }; + description?: string; + args: { code?: unknown; timeoutMs?: unknown; sessionId?: unknown }; execute(args: unknown, context: unknown): Promise; }; + expect(runTool.description).toContain("meta.sessionId"); expect(runTool.args).toMatchObject({ code: { type: "string" }, timeoutMs: { type: "number", optional: true }, + sessionId: { type: "string", optional: true }, }); const runResult = await runTool.execute({ code: "return {ok:true};" }, {} as never); expect(service.execute).toHaveBeenCalledWith("code_mode", { code: "return {ok:true};" }); expect(runResult).toContain('"ok": true'); + expect(JSON.parse(runResult)).toMatchObject({ + meta: { + runId: "run-1", + traceId: "trace-1", + declarationHash: "hash-1", + sessionId: "session-1", + sessionStatus: "created", + recoveryRef: "recovery-1", + timeoutMs: 10000, + maxTimeoutMs: 10000, + durationMs: 25, + }, + }); + await runTool.execute({ code: "return {ok:true};", sessionId: "" }, {} as never); + expect(service.execute).toHaveBeenLastCalledWith("code_mode", { code: "return {ok:true};" }); + await runTool.execute({ code: "return {ok:true};", sessionId: "session-1" }, {} as never); + expect(service.execute).toHaveBeenLastCalledWith("code_mode", { + code: "return {ok:true};", + sessionId: "session-1", + }); + await runTool.execute({ code: "return {ok:true};", sessionId: "session-2" }, {} as never); + expect(service.execute).toHaveBeenLastCalledWith("code_mode", { + code: "return {ok:true};", + sessionId: "session-2", + }); const output = { system: [] as string[] }; await hooks["experimental.chat.system.transform"]?.({} as never, output); @@ -251,11 +303,9 @@ describe("@caplets/opencode", () => { {} as never, { mode: "remote", - server: { + remote: { url: "https://caplets.example.com", user: "caplets", - }, - remote: { pollIntervalMs: 5_000, }, } as never, @@ -263,11 +313,9 @@ describe("@caplets/opencode", () => { expect(nativeMocks.createNativeCapletsService).toHaveBeenCalledWith({ mode: "remote", - server: { + remote: { url: "https://caplets.example.com", user: "caplets", - }, - remote: { pollIntervalMs: 5_000, }, }); @@ -290,12 +338,12 @@ describe("@caplets/opencode", () => { await plugin( {} as never, - { mode: "cloud", server: { url: "https://cloud.caplets.dev" } } as never, + { mode: "cloud", remote: { url: "https://cloud.caplets.dev" } } as never, ); expect(nativeMocks.createNativeCapletsService).toHaveBeenCalledWith({ mode: "cloud", - server: { url: "https://cloud.caplets.dev" }, + remote: { url: "https://cloud.caplets.dev" }, }); }); diff --git a/packages/pi/README.md b/packages/pi/README.md index 77dee480..cc14f3d0 100644 --- a/packages/pi/README.md +++ b/packages/pi/README.md @@ -56,11 +56,9 @@ options are supplied: "packages": ["npm:@caplets/pi"], "caplets": { "mode": "remote", - "server": { - "url": "https://caplets.example.com/caplets", - "user": "caplets" - }, "remote": { + "url": "https://caplets.example.com/caplets", + "user": "caplets", "pollIntervalMs": 5000 }, "statusWidget": true, @@ -84,17 +82,15 @@ import { createCapletsPiExtension } from "@caplets/pi"; export default createCapletsPiExtension({ args: { mode: "remote", - server: { + remote: { url: "https://caplets.example.com/caplets", user: "caplets", - }, - remote: { pollIntervalMs: 5_000, }, }, }); ``` -The explicit config shape is `{ mode, server: { url, user }, remote: { pollIntervalMs } }`. +The explicit config shape is `{ mode, remote: { url, user, pollIntervalMs } }`. Prefer environment variables for `CAPLETS_REMOTE_TOKEN` or `CAPLETS_REMOTE_PASSWORD` rather than storing passwords in settings files or source code. diff --git a/packages/pi/src/index.ts b/packages/pi/src/index.ts index 091b5d4e..ff95d576 100644 --- a/packages/pi/src/index.ts +++ b/packages/pi/src/index.ts @@ -17,7 +17,7 @@ import { type NativeCapletsServiceOptions, } from "@caplets/core/native"; -type PiNativeCapletsOptions = Pick; +type PiNativeCapletsOptions = Pick; export type PiExtensionApi = Pick & Partial> & { @@ -258,33 +258,44 @@ function topLevelCapletsOptions( function parsePiNativeOptions( value: unknown, - writeWarning?: (message: string) => void, - path = "settings.caplets", + _writeWarning?: (message: string) => void, + _path = "settings.caplets", ): PiCapletsSettings | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { return undefined; } + const raw = value as Record; const result: PiCapletsSettings = {}; - const mode = (value as Record).mode; + const mode = raw.mode; if (mode !== undefined) { if (mode !== "auto" && mode !== "local" && mode !== "remote" && mode !== "cloud") { return undefined; } result.mode = mode; } - const statusWidget = (value as Record).statusWidget; + const statusWidget = raw.statusWidget; if (statusWidget !== undefined) { if (typeof statusWidget !== "boolean") return undefined; result.statusWidget = statusWidget; } - const nerdFontIcons = (value as Record).nerdFontIcons; + const nerdFontIcons = raw.nerdFontIcons; if (nerdFontIcons !== undefined) { if (typeof nerdFontIcons !== "boolean") return undefined; result.nerdFontIcons = nerdFontIcons; } const remote = objectProperty(value, "remote"); + if (raw.remote !== undefined && !remote) { + return undefined; + } if (remote) { const parsedRemote: NonNullable = {}; + for (const key of ["url", "user", "password", "token", "workspace"] as const) { + const field = remote[key]; + if (field !== undefined) { + if (typeof field !== "string") return undefined; + parsedRemote[key] = field; + } + } const pollIntervalMs = remote.pollIntervalMs; if (pollIntervalMs !== undefined) { if (typeof pollIntervalMs !== "number" || !Number.isFinite(pollIntervalMs)) return undefined; @@ -292,47 +303,12 @@ function parsePiNativeOptions( } result.remote = parsedRemote; } - const server = objectProperty(value, "server"); - const parsedServer = parsePiServerOptions(server); - if (parsedServer === undefined && server) { + if (raw.server !== undefined) { return undefined; } - const legacyServer = parsePiServerOptions(remote); - if (legacyServer === undefined && remote && hasLegacyRemoteServerFields(remote)) { - return undefined; - } - if (legacyServer && !parsedServer) { - writeWarning?.( - `[caplets/pi] ${path}.remote.url is deprecated; move remote.url/user/password to server.url/user/password.`, - ); - } - if (legacyServer || parsedServer) { - result.server = { ...legacyServer, ...parsedServer }; - } return result; } -function parsePiServerOptions( - value: Record | undefined, -): NonNullable | undefined { - if (!value) { - return undefined; - } - const parsedServer: NonNullable = {}; - for (const key of ["url", "user", "password"] as const) { - const field = value[key]; - if (field !== undefined) { - if (typeof field !== "string") return undefined; - parsedServer[key] = field; - } - } - return Object.keys(parsedServer).length > 0 ? parsedServer : undefined; -} - -function hasLegacyRemoteServerFields(remote: Record): boolean { - return remote.url !== undefined || remote.user !== undefined || remote.password !== undefined; -} - function capletsRemoteStatusText(status: "connected" | "offline", nerdFontIcons: boolean): string { if (nerdFontIcons) { return status === "connected" ? "󰖟 caplets ✓" : "󰖟 caplets ×"; @@ -343,7 +319,6 @@ function capletsRemoteStatusText(status: "connected" | "offline", nerdFontIcons: function nativeServiceOptions(options: PiCapletsSettings): PiNativeCapletsOptions { return { ...(options.mode ? { mode: options.mode } : {}), - ...(options.server ? { server: options.server } : {}), ...(options.remote ? { remote: options.remote } : {}), }; } @@ -361,8 +336,8 @@ function shouldShowStatusWidget( return ( options.mode === "remote" || options.mode === "cloud" || - !!options.server?.url || - process.env.CAPLETS_SERVER_URL !== undefined + !!options.remote?.url || + process.env.CAPLETS_REMOTE_URL !== undefined ); } @@ -634,8 +609,7 @@ function codeModeAgentContent(result: unknown): Array<{ type: "text"; text: stri if (diagnostics.length > 0) compact.diagnostics = diagnostics; const logSummary = codeModeLogSummary(logs); if (logSummary) compact.logs = logSummary; - const durationMs = meta?.durationMs; - if (typeof durationMs === "number") compact.durationMs = durationMs; + if (meta) compact.meta = meta; return [{ type: "text", text: JSON.stringify(compact) ?? "null" }]; } diff --git a/packages/pi/test/pi.test.ts b/packages/pi/test/pi.test.ts index e7f98a88..ab70d000 100644 --- a/packages/pi/test/pi.test.ts +++ b/packages/pi/test/pi.test.ts @@ -106,6 +106,56 @@ describe("@caplets/pi", () => { expect(registered[0]?.parameters).toEqual(generatedToolInputJsonSchema()); }); + it("registers Code Mode reuse guidance and session parameters", () => { + const service = mockService([ + { + caplet: "code_mode", + toolName: "caplets__code_mode", + title: "Code Mode", + description: + "Run Caplets Code Mode. Omit sessionId to start fresh and pass returned meta.sessionId to reuse live state.", + promptGuidance: [ + "For REPL reuse, omit sessionId to start fresh, then pass the returned meta.sessionId on later calls that should reuse live state.", + "Unknown or unavailable sessionId values fail before code execution; use meta.recoveryRef with caplets.debug.readRecovery({ recoveryRef }) for audit and manual reconstruction, not automatic replay.", + ], + inputSchema: { + type: "object", + properties: { + code: { type: "string" }, + sessionId: { + type: "string", + description: + "Omit to create a fresh reusable session; pass a known live session ID from meta.sessionId to reuse existing REPL state.", + }, + }, + required: ["code"], + additionalProperties: false, + }, + }, + ]); + const registered: RegisteredTool[] = []; + + createCapletsPiExtension({ service })({ + registerTool: (definition) => registered.push(definition as unknown as RegisteredTool), + }); + + expect(registered[0]).toMatchObject({ + name: "caplets__code_mode", + description: expect.stringContaining("meta.sessionId"), + promptGuidelines: expect.arrayContaining([ + expect.stringContaining("omit sessionId to start fresh"), + expect.stringContaining("meta.recoveryRef"), + ]), + parameters: expect.objectContaining({ + properties: expect.objectContaining({ + sessionId: expect.objectContaining({ + description: expect.stringContaining("Omit to create a fresh reusable session"), + }), + }), + }), + }); + }); + it("registers prefixed native tools with explicit prompt guidance", async () => { const service = mockService([ { @@ -249,6 +299,9 @@ describe("@caplets/pi", () => { runId: "run-1", traceId: "trace-1", declarationHash: "hash-1", + sessionId: "session-1", + sessionStatus: "created", + recoveryRef: "recovery-1", timeoutMs: 10000, maxTimeoutMs: 10000, durationMs: 25, @@ -268,6 +321,17 @@ describe("@caplets/pi", () => { id: "BENCH-451", title: "Checkout authorization retry double-submit", }); + expect(parsed.meta).toEqual({ + runId: "run-1", + traceId: "trace-1", + declarationHash: "hash-1", + sessionId: "session-1", + sessionStatus: "created", + recoveryRef: "recovery-1", + timeoutMs: 10000, + maxTimeoutMs: 10000, + durationMs: 25, + }); expect(parsed.value.descriptor).toEqual({ id: "api", tool: { name: "lookup_schema", description: "Lookup an API schema." }, @@ -664,8 +728,8 @@ describe("@caplets/pi", () => { const service = mockService([]); const args = { mode: "remote", - server: { url: "https://caplets.example.com", user: "pi-user" }, - } satisfies Pick; + remote: { url: "https://caplets.example.com", user: "pi-user" }, + } satisfies Pick; nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); createCapletsPiExtension({ args })({ registerTool: vi.fn() }); @@ -963,7 +1027,7 @@ describe("@caplets/pi", () => { packages: ["npm:@caplets/pi"], caplets: { mode: "remote", - server: { url: "http://localhost:5387" }, + remote: { url: "http://localhost:5387" }, }, }), ); @@ -983,11 +1047,11 @@ describe("@caplets/pi", () => { ); expect(nativeMocks.createNativeCapletsService).toHaveBeenLastCalledWith({ mode: "remote", - server: { url: "http://localhost:5387" }, + remote: { url: "http://localhost:5387" }, }); }); - it("loads deprecated remote server fields with a warning", async () => { + it("loads remote URL fields from Pi settings", async () => { const writeWarning = vi.fn(); fsMocks.readFile.mockResolvedValueOnce( JSON.stringify({ @@ -1009,16 +1073,69 @@ describe("@caplets/pi", () => { expect(args).toEqual({ mode: "remote", - server: { + remote: { url: "https://caplets.example.com", user: "ian", password: "test-password", - }, - remote: { pollIntervalMs: 1_000, }, }); - expect(writeWarning).toHaveBeenCalledWith(expect.stringContaining("remote.url is deprecated")); + expect(writeWarning).not.toHaveBeenCalled(); + }); + + it("rejects native server settings in Pi config", async () => { + const writeWarning = vi.fn(); + fsMocks.readFile.mockResolvedValueOnce( + JSON.stringify({ + packages: ["npm:@caplets/pi"], + caplets: { + mode: "remote", + server: { + url: "https://caplets.example.com", + }, + }, + }), + ); + fsMocks.readFile.mockRejectedValueOnce(Object.assign(new Error("missing"), { code: "ENOENT" })); + + const args = await loadPiSettingsArgs({ writeWarning }); + + expect(args).toEqual({}); + expect(writeWarning).toHaveBeenCalledWith( + expect.stringContaining("Ignoring Pi settings args: invalid"), + ); + }); + + it("rejects malformed legacy remote and server settings in Pi config", async () => { + const writeWarning = vi.fn(); + fsMocks.readFile.mockResolvedValueOnce( + JSON.stringify({ + packages: ["npm:@caplets/pi"], + caplets: { + mode: "remote", + remote: "https://caplets.example.com", + }, + }), + ); + fsMocks.readFile.mockResolvedValueOnce( + JSON.stringify({ + packages: ["npm:@caplets/pi"], + caplets: { + mode: "remote", + server: "https://caplets.example.com", + }, + }), + ); + + const malformedRemoteArgs = await loadPiSettingsArgs({ writeWarning }); + const malformedServerArgs = await loadPiSettingsArgs({ writeWarning }); + + expect(malformedRemoteArgs).toEqual({}); + expect(malformedServerArgs).toEqual({}); + expect(writeWarning).toHaveBeenCalledTimes(2); + expect(writeWarning).toHaveBeenCalledWith( + expect.stringContaining("Ignoring Pi settings args: invalid"), + ); }); it("default export loads top-level Pi settings for the native service", async () => { @@ -1029,11 +1146,9 @@ describe("@caplets/pi", () => { packages: ["npm:@caplets/pi"], caplets: { mode: "remote", - server: { + remote: { url: "https://caplets.example.com", user: "ian", - }, - remote: { pollIntervalMs: 1_000, }, }, @@ -1046,11 +1161,9 @@ describe("@caplets/pi", () => { expect(nativeMocks.createNativeCapletsService).toHaveBeenLastCalledWith({ mode: "remote", - server: { + remote: { url: "https://caplets.example.com", user: "ian", - }, - remote: { pollIntervalMs: 1_000, }, }); @@ -1062,7 +1175,7 @@ describe("@caplets/pi", () => { fsMocks.readFile.mockImplementation(async (path: string) => path.includes(".pi/agent/settings.json") ? JSON.stringify({ - caplets: { mode: "cloud", server: { url: "https://cloud.caplets.dev" } }, + caplets: { mode: "cloud", remote: { url: "https://cloud.caplets.dev" } }, }) : Promise.reject(Object.assign(new Error("missing"), { code: "ENOENT" })), ); @@ -1073,7 +1186,7 @@ describe("@caplets/pi", () => { expect(nativeMocks.createNativeCapletsService).toHaveBeenCalledWith( expect.objectContaining({ mode: "cloud", - server: { url: "https://cloud.caplets.dev" }, + remote: { url: "https://cloud.caplets.dev" }, }), ); }); @@ -1086,7 +1199,7 @@ describe("@caplets/pi", () => { packages: [ { source: "npm:@caplets/pi", - args: { mode: "remote", server: { url: "https://ignored.example.com" } }, + args: { mode: "remote", remote: { url: "https://ignored.example.com" } }, }, ], }), @@ -1150,7 +1263,7 @@ describe("@caplets/pi", () => { packages: ["npm:@caplets/pi"], caplets: { mode: "remote", - server: { url: "https://caplets.example.com" }, + remote: { url: "https://caplets.example.com" }, }, }), ); @@ -1174,7 +1287,7 @@ describe("@caplets/pi", () => { packages: ["npm:@caplets/pi"], caplets: { mode: "remote", - server: { url: "https://caplets.example.com" }, + remote: { url: "https://caplets.example.com" }, nerdFontIcons: false, }, }), @@ -1199,7 +1312,7 @@ describe("@caplets/pi", () => { packages: ["npm:@caplets/pi"], caplets: { mode: "remote", - server: { url: "https://caplets.example.com" }, + remote: { url: "https://caplets.example.com" }, statusWidget: false, }, }), @@ -1220,7 +1333,7 @@ describe("@caplets/pi", () => { fsMocks.readFile.mockResolvedValueOnce( JSON.stringify({ packages: ["npm:@caplets/pi"], - caplets: { mode: "remote", server: { url: "https://caplets.example.com" } }, + caplets: { mode: "remote", remote: { url: "https://caplets.example.com" } }, }), ); const { api } = mockPiApi(); diff --git a/scripts/check-public-docs.ts b/scripts/check-public-docs.ts index 9e3738c7..55122b0d 100644 --- a/scripts/check-public-docs.ts +++ b/scripts/check-public-docs.ts @@ -41,7 +41,10 @@ const requiredContent = new Map([ ], ], ["configuration.mdx", ["https://caplets.dev/config.schema.json", ".caplets/config.json"]], - ["code-mode.mdx", ["caplets__code_mode", "caplets.osv.searchTools", "caplets.osv.callTool"]], + [ + "code-mode.mdx", + ["caplets__code_mode", "caplets.osv.searchTools", "caplets.osv.callTool", "sessionId"], + ], ["capabilities.mdx", ["CAPLET.md", "OpenAPI", "GraphQL", "MCP"]], ["agent-integrations.mdx", ["Codex", "Claude", "OpenCode", "Pi"]], ["remote-attach.mdx", ["caplets attach", "CAPLETS_MODE=remote"]], diff --git a/scripts/generate-docs-reference.ts b/scripts/generate-docs-reference.ts index 6dbeee36..b029d9a8 100644 --- a/scripts/generate-docs-reference.ts +++ b/scripts/generate-docs-reference.ts @@ -378,6 +378,32 @@ function codeModeApiRecipes(): string { "};", "```", "", + "Reuse a live session only when the calling client passes the `sessionId` returned in", + "`meta.sessionId` from a successful fresh or reused run. Unknown or expired sessions fail", + "before submitted code runs, and heap state is not durable across process restarts or TTL", + "eviction.", + "", + "Read recovery history only with a `recoveryRef` that was already returned when the session", + "was created:", + "", + "```ts", + "const history = await caplets.debug.readRecovery({", + ' recoveryRef: "code-mode-recovery-ref",', + " limit: 20,", + "});", + "", + "return {", + " nextCursor: history.nextCursor,", + " setupCandidates: history.entries", + ' .filter((entry) => entry.recoveryClassification === "setup_like")', + " .map((entry) => ({ summary: entry.summary, ok: entry.outcome.ok })),", + "};", + "```", + "", + "Recovery returns redacted, bounded summaries for reconstructing setup code manually. It does", + "not restore heap, closures, timers, promises, or host handles, and a stale `sessionId` does", + "not grant a recovery ref.", + "", "Read execution logs through the generated debug handle when a Caplet returns a log ref:", "", "```ts", @@ -400,14 +426,38 @@ function codeModeApiPage(): string { "utf8", ); const blocks = [ + "type JsonPrimitive", + "type JsonValue", "interface CapletHandle", "interface DebugApi", + "type CapletCard", + "type PageInput", "type Page", "type CapletsResult", + "type CapletsMeta", + "type CapletsError", + "type BackendCheckResult", "type ToolSummary", "type ToolDescriptor", + "type ObservedOutputShape", + "type JsonShape", + "type ResourceSummary", + "type ResourceTemplateSummary", + "type ResourceReadResult", + "type PromptSummary", + "type PromptResult", + "type CompleteInput", + "type CompleteResult", "type ReadLogsInput", "type ReadLogsResult", + "type ReadCodeModeRecoveryInput", + "type CodeModeDiagnostic", + "type CodeModeRecoveryClassification", + "type CodeModeRecoveryEntry", + "type ReadCodeModeRecoveryResult", + "type CodeModeLogEntry", + "type CodeModeSessionStatus", + "type CodeModeRunMeta", ] .map((name) => extractDeclaration(source, name)) .filter((block): block is string => Boolean(block)); @@ -447,6 +497,9 @@ function extractDeclaration(source: string, declaration: string): string | undef const lineEnd = source.indexOf("\n", start); const declarationLine = source.slice(start, lineEnd === -1 ? undefined : lineEnd); if (!declarationLine.includes("{")) { + if (declarationLine.trimEnd().endsWith(";")) { + return declarationLine.trim(); + } const nextDeclaration = /\n(?:interface|type)\s+[A-Za-z_$]/u.exec(source.slice(lineEnd + 1)); const end = nextDeclaration ? lineEnd + 1 + nextDeclaration.index : source.length; return source.slice(start, end).trim();