@tea-agent/loop-agent 0.33.7-beta.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +38 -17
  2. package/dist/application/task-lifecycle/advance.js +254 -4
  3. package/dist/application/task-lifecycle/gates.js +50 -0
  4. package/dist/application/task-lifecycle/observe.js +11 -2
  5. package/dist/commands/init-upgrade.js +32 -1
  6. package/dist/commands/init.js +94 -3
  7. package/dist/executors/shell-executor.js +4 -91
  8. package/dist/executors/shell-write-guard.js +26 -8
  9. package/dist/shared/operator/capabilities.js +72 -42
  10. package/dist/shared/resilient-git.js +133 -0
  11. package/dist/task/source-prepare/artifact-meta.js +137 -0
  12. package/dist/task/source-prepare/index.js +2 -0
  13. package/dist/task/source-prepare/parse-intent.js +58 -10
  14. package/dist/task/source-prepare/prepare.js +180 -16
  15. package/dist/task/source-prepare/reference-integrity.js +18 -2
  16. package/dist/task/source-prepare/semantic-intake.js +404 -0
  17. package/dist/worker/console/app-data.js +2 -0
  18. package/dist/worker/console/chat/chat-event-store.js +190 -25
  19. package/dist/worker/console/chat/model-resolver.js +17 -0
  20. package/dist/worker/console/chat/pi-console-config.js +250 -32
  21. package/dist/worker/console/chat/pi-runtime.js +1007 -188
  22. package/dist/worker/console/chat/resource-loader.js +5 -4
  23. package/dist/worker/console/chat/routes.js +495 -157
  24. package/dist/worker/console/chat/runtime-context.js +48 -12
  25. package/dist/worker/console/chat/runtime-selection.js +59 -0
  26. package/dist/worker/console/chat/session-store.js +39 -0
  27. package/dist/worker/console/chat/shortcuts.js +1 -0
  28. package/dist/worker/console/chat/tool-adapter.js +9 -3
  29. package/dist/worker/console/chat/tools.js +5 -1
  30. package/dist/worker/console/dag-execution-receipt.js +380 -0
  31. package/dist/worker/console/operator-actions.js +559 -68
  32. package/dist/worker/console/server.js +8 -15
  33. package/dist/worker/console/static/assets/index-BQkhJpV8.css +1 -0
  34. package/dist/worker/console/static/assets/index-CMHovlqG.js +32 -0
  35. package/dist/worker/console/static/index.html +2 -2
  36. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
  37. package/dist/worker/console/static-src/operator-chat/landing-density.js +23 -0
  38. package/dist/worker/console/static-src/operator-chat/refs.js +9 -0
  39. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +257 -0
  40. package/dist/worker/console/static-src/operator-chat/session-title-watcher.js +128 -0
  41. package/dist/worker/console/static-src/operator-chat/sidebar-split.js +90 -0
  42. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +37 -0
  43. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +125 -22
  44. package/dist/worker/console/static-src/operator-chat/useChatStream.js +215 -184
  45. package/dist/worker/console/static-src/operator-chat/useChatThread.js +49 -5
  46. package/dist/worker/console/static-src/operator-chat/useComposer.js +17 -0
  47. package/dist/worker/console/static-src/operator-chat/useOverlayFocus.js +84 -0
  48. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +225 -74
  49. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +196 -0
  50. package/dist/worker/console/static-src/operator-chat/useWorkspaceLayout.js +58 -0
  51. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +31 -0
  52. package/dist/worker/delivery/final-verification.js +13 -5
  53. package/dist/worker/delivery/package.js +31 -19
  54. package/dist/worker/delivery/verification-bundle.js +6 -4
  55. package/dist/worker/observe/static/operator-chrome.css +5 -2
  56. package/dist/worker/observe/static/operator-chrome.js +6 -1
  57. package/dist/worker/observe/static/styles.css +39 -9
  58. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +33 -462
  59. package/dist/workflows/dag/backend-test-case-manifest.js +0 -4
  60. package/dist/workflows/dag/backend-test-markdown-workflow.js +1 -25
  61. package/dist/workflows/dag/backend-test-module-stem.js +0 -5
  62. package/dist/workflows/dag/backend-test-pytest-collection.js +24 -345
  63. package/dist/workflows/dag/backend-test-scenario-param.js +82 -269
  64. package/dist/workflows/dag/backend-test-writer-completeness.js +16 -47
  65. package/dist/workflows/dag/dynamic-runtime/map.js +8 -24
  66. package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
  67. package/dist/workflows/dag/init-hybrid.js +34 -46
  68. package/dist/workflows/dag/types.js +0 -7
  69. package/dist/workflows/dag/workspace-checkpoint.js +8 -27
  70. package/docs/templates/backend-test-dag.json +29 -32
  71. package/harness.json +1 -1
  72. package/package.json +1 -1
  73. package/skills/local-jacoco-coverage/SKILL.md +281 -0
  74. package/skills/local-jacoco-coverage/references/requirement-to-source-mapping.md +85 -0
  75. package/skills/local-jacoco-coverage/references/runtime-alignment.md +106 -0
  76. package/skills/local-jacoco-coverage/scripts/run-coverage-analysis.sh +148 -0
  77. package/skills/local-jacoco-coverage/scripts/start-jacoco-agent.sh +110 -0
  78. package/skills/loop-agent/references/command-reference.md +3 -1
  79. package/skills/loop-agent/references/source-and-plan-practice.md +13 -0
  80. package/skills/loop-agent/references/task-workflow.md +4 -0
  81. package/dist/worker/console/chat/instruction-skills.js +0 -217
  82. package/dist/worker/console/static/assets/index-CnUXAqxG.css +0 -1
  83. package/dist/worker/console/static/assets/index-CteJFFL2.js +0 -29
@@ -9,60 +9,64 @@
9
9
  * - Chat sessions are persisted to a DEDICATED sessionDir (not the user's
10
10
  * default ~/.pi/agent/sessions). Naming prefix `operator-chat-` makes them
11
11
  * auditable and cleanable (plan Q4 / Q8).
12
- * - The ResourceLoader is configured to NOT auto-load user extensions/skills
13
- * (noContextFiles / closed surface); credential/model plane is shared via
14
- * the same agentDir auth.json/models.json.
12
+ * - The ResourceLoader uses the SDK's standard OPEN surface (ADR 0012):
13
+ * user/project AGENTS.md context files, user (~/.pi/agent/skills/) and
14
+ * project (.agents/skills/ init projection) skills, and user/project
15
+ * extensions all load via the SDK DefaultResourceLoader; credential/model
16
+ * plane is shared via the same agentDir auth.json/models.json.
15
17
  * - Active tools are pinned to the operator-chat surface at session create
16
18
  * AND re-pinned before each prompt (three-gate, design §7.5 / ADR 0011):
17
19
  * the FULL operator action set PLUS full Pi builtins
18
- * (read/write/edit/bash/grep/find/ls) and optional safe-*. Non-Pi write
20
+ * (read/write/edit/bash/grep/find/ls) and optional safe-*, PLUS extension
21
+ * tools registered by user/project extensions (ADR 0012). Non-Pi write
19
22
  * channels (apply_patch/full-tools/shell/coding-chat) stay excluded.
20
23
  *
21
24
  * The actual SDK calls are injected via `PiSdkBindings` so this module is
22
25
  * unit-testable without a live Pi install. Production bindings come from
23
26
  * `createDefaultPiSdkBindings()`.
24
27
  */
25
- import { randomBytes } from "node:crypto";
28
+ import { createHash, randomBytes } from "node:crypto";
26
29
  import { access } from "node:fs/promises";
27
30
  import path from "node:path";
28
31
  import { projectCompactSnapshot, } from "./chat-event-store.js";
29
32
  import { extractUsageSample } from "./usage.js";
30
33
  import { OPERATOR_CHAT_ALLOWED_TOOLS, authorizeOperatorChatTool, assertNoWriteToolInList, } from "./tools.js";
31
34
  import { createOperatorChatResourceLoader, } from "./resource-loader.js";
32
- import { loadOperatorChatInstructionSkills, composeInstructionSkillsPrompt, OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS, } from "./instruction-skills.js";
33
35
  import { buildModelCallableToolSchemas } from "./tool-adapter.js";
34
- import { resolveDefaultChatModel, } from "./model-resolver.js";
36
+ import { resolveDefaultChatModel, resolveLowChatModel, } from "./model-resolver.js";
35
37
  import { filterActiveInterviewTools } from "../interview/tools.js";
38
+ import { RUNTIME_CONTEXT_TEXT_MAX, redactRuntimeText, } from "./runtime-context.js";
36
39
  /**
37
40
  * Base system prompt fragment every General Operator Chat session receives.
38
41
  *
39
- * Establishes the operator-vs-implementer role boundary (ADR 0005 D2) and the
40
- * current tool surface contract (2026-07-25 widening): the Chat exposes the
41
- * FULL operator action surface (all registry actions, including high-risk
42
- * mutations like contractApply / runDag / dagRerun) PLUS the built-in
43
- * read/explore tools (bash / read / grep / find / ls). The ONLY thing still
44
- * forbidden is direct file-WRITING via coding tools (edit / write /
45
- * apply_patch / full-tools / shell / coding-chat) those are excluded from
46
- * the SDK registry entirely and can never be activated.
42
+ * 2026-08-11 (ADR 0013): this is a SHORT runtime responsibility layer only.
43
+ * Project governance (operator-only policy, allowedPaths/forbiddenPaths,
44
+ * writer writeSet, taskKind routing, verification matrix, DAG recovery
45
+ * commands, closeout) lives in the loaded target-project AGENTS.md. The
46
+ * prompt below covers: Operator identity, the real tool surface (operator_*
47
+ * first + Pi tools, obey AGENTS.md), denied non-Pi runtimes, safe-* probes,
48
+ * init/init-upgrade fallback for uninitialized repos, DAG supervision with
49
+ * 15/30/60s adaptive polling (operationGet/status/dagReport/dagDoctor), R2
50
+ * recovery (fresh eligible dagRerunPlan → dagRerun; ineligible → same-task
51
+ * rerun/advance, resume, Worker retry) and G2 bounded writeSet auto-approval.
47
52
  *
48
- * Instruction skills are appended AFTER this as read-only context (plan D1).
53
+ * The old Human Gate tutorial paragraphs (prepare + browser Human Gate,
54
+ * "confirmDagConfirmation is a human-only action", the long "Recovery default
55
+ * order" table) are REMOVED — bounded DAGs start and recover autonomously
56
+ * (AC-01: ≤ 1900 chars and ≤ 80% of the 2498-char baseline).
49
57
  */
50
58
  export const OPERATOR_CHAT_SYSTEM_PROMPT_BASE = [
51
59
  "You are the General Operator Chat for loop-agent / agent-worker.",
52
- "You are an OPERATOR first: orchestrate and inspect via operator_* tools, and you also have full Pi repository tools (read, write, edit, bash, grep, find, ls) plus optional safe-read/safe-grep/git-status/git-diff.",
53
- "Capability honesty (ADR 0011): read/write/edit/bash ARE available. Prefer governed loop-agent / Agent DAG paths (implement-pi / repair-pi) and Human Gate for large refactors, public contracts, credentials, or production-risk changes treat direct write/edit/bash as soft-disciplined, not as a second DAG kernel.",
54
- "safe-read / safe-grep enforce a repo-relative path boundary, a sensitive-file denylist (.env* / *.key / *.pem / .git/** / auth.json / sessions/**) and secret scrubbing. Prefer them for sensitive probes; avoid dumping secrets via raw read/bash.",
55
- "You do NOT have apply_patch / full-tools / shell / coding-chat as alternate coding runtimes those non-Pi channels stay denied.",
56
- "High-risk mutations still use prepare + browser Human Gate. Prepare contract via interview then apply via contractApply, prepare DAG runs via prepareDagConfirmation, prepare other mutations via prepareMutationGate, then confirm in the browser Human Gate. When a high-risk action (contractApply / runDag / dagRerun / etc.) fails on missing prepared state, diagnose with status/doctor/dagReport/inspect/contractShow/read/safe-read/safe-grep.",
57
- "You cannot self-confirm a DAG run: confirmDagConfirmation is a human-only action (executed by the browser with a server-signed confirmation token). You may only prepare it via prepareDagConfirmation; the user must confirm in the UI.",
58
- "Prefer read-only diagnosis (status, doctor, dagReport, inspect, contractShow, read, safe-read, safe-grep, git-status) before mutating.",
59
- "Recovery default order (save tokens/time; do NOT open a new task-id for provider flake): (1) dagReport/doctor/status — read primaryFailure.nodeId + primaryRecovery; (2) for completed failed|partial_failed standalone runs prefer dagRerunPlan then prepareMutationGate/dagRerun from the failed or safe upstream node (plan → planHash → human gate); (3) paused → approve path / resume, never treat resume as failure retry; (4) only when plan is ineligible (writer/decision/fingerprint) or contract/source truly changed: standaloneTaskRerun or same-task task advance — never invent a fresh task-id without cause; (5) Worker-owned failures use workerTaskRetry, not dagRerun. Never use direct write/edit as a substitute for repair-pi.",
60
+ "Operate through operator_* tools first. Pi read/write/edit/bash/grep/find/ls are available, but obey the loaded repository AGENTS.md. apply_patch, full-tools, shell, and coding-chat are denied alternate runtimes. Prefer safe-read/safe-grep for sensitive probes.",
61
+ "If a repository has no loop-agent harness and the user requests initialization, run `loop-agent init instructions --repo-root .` then `loop-agent init --repo-root . --profile full --merge`; finish the generated setup, init doctor, inspect, docs audit, and quick verification. For updates use `loop-agent init upgrade --repo-root . --json` until stable; use init check-update only for an explicitly read-only request.",
62
+ "After a DAG is started or accepted, do not end on accepted/queued/running or an operationId. Supervise it to a terminal outcome. Poll operationGet, task status, dagReport, and dagDoctor after about 15 seconds on start/change, every 30 seconds during progress, and every 60 seconds after 3 minutes unchanged. Report only meaningful node/rank changes, review/verify/closeout, recovery, liveness concerns, and terminal outcomes.",
63
+ "On failure read primaryFailure, primaryRecovery, and doctor evidence. If meaningful progress exists, wait. Otherwise use a fresh eligible dagRerunPlan and rerun its safe node; when ineligible follow AGENTS.md/runtime recovery for same-task rerun/advance, resume, or Worker retry. Auto-approve only a bounded writeSet inside allowedPaths, outside forbiddenPaths, without broad/destructive risk, and with structured verification.",
64
+ "Continue until success, user stop, or no safe eligible recovery remains because limits, bindings, auth/quota recovery, or required external authorization are exhausted. Never create a new task for a transient failure or replace repair-pi with direct edits.",
60
65
  ].join("\n");
61
66
  /** Compose the inspectable system prompt actually injected into Operator Chat. */
62
67
  export function composeOperatorChatSystemPrompt(input) {
63
68
  return [
64
69
  OPERATOR_CHAT_SYSTEM_PROMPT_BASE,
65
- ...(input.skillFragment?.trim() ? [input.skillFragment.trim()] : []),
66
70
  ...(input.systemPromptSuffix?.trim()
67
71
  ? [input.systemPromptSuffix.trim()]
68
72
  : []),
@@ -102,6 +106,49 @@ export function OPERATOR_CHAT_ACTIVE_TOOL_NAMES() {
102
106
  ...OPERATOR_CHAT_SAFE_EXPLORE_TOOL_IDS,
103
107
  ];
104
108
  }
109
+ /**
110
+ * Non-Pi write / coding channels that must never be activated in Operator
111
+ * Chat even when a user/project extension registers them (ADR 0011 / 0012).
112
+ * Mirrors the NON_PI_WRITE_CHANNELS guard in tools.ts for the Gate 2 pin.
113
+ */
114
+ const OPERATOR_CHAT_EXTENSION_DENIED_TOOL_IDS = new Set([
115
+ "apply_patch",
116
+ "apply-patch",
117
+ "full-tools",
118
+ "full_tools",
119
+ "coding-chat",
120
+ "coding_chat",
121
+ "shell",
122
+ ].map((t) => t.toLowerCase()));
123
+ /**
124
+ * Gate 2 active-tool set for a live session (ADR 0012): the operator-chat
125
+ * baseline (operator actions + Pi builtins + safe explore) PLUS extension
126
+ * tools registered by user/project extensions. SDK setActiveToolsByName is
127
+ * REPLACEMENT-style, so extension tools would be disabled by the fixed
128
+ * baseline; this helper re-adds every registry tool that is not part of the
129
+ * baseline and not on the deny surface (apply_patch/full-tools/shell/
130
+ * coding-chat). Unknown/denied names are never activated; Gate 3 still
131
+ * re-authorizes every tool call per invocation.
132
+ */
133
+ export function computeOperatorChatActiveToolNames(session) {
134
+ const baseline = OPERATOR_CHAT_ACTIVE_TOOL_NAMES();
135
+ if (!session?.getAllTools)
136
+ return baseline;
137
+ const baselineLower = new Set(baseline.map((t) => t.toLowerCase()));
138
+ const extras = [];
139
+ for (const tool of session.getAllTools()) {
140
+ const name = tool?.name;
141
+ if (!name || typeof name !== "string")
142
+ continue;
143
+ const lower = name.toLowerCase();
144
+ if (baselineLower.has(lower))
145
+ continue;
146
+ if (OPERATOR_CHAT_EXTENSION_DENIED_TOOL_IDS.has(lower))
147
+ continue;
148
+ extras.push(name);
149
+ }
150
+ return extras.length > 0 ? [...baseline, ...extras] : baseline;
151
+ }
105
152
  /**
106
153
  * Build Pi custom ToolDefinition objects for every whitelisted operator action.
107
154
  * Each tool's execute() routes into the operator action dispatcher (Gate 3).
@@ -382,6 +429,138 @@ export const THINKING_LEVELS = [
382
429
  "xhigh",
383
430
  "max",
384
431
  ];
432
+ /**
433
+ * Structured critical-failure error for snapshot projection (RF-02). Model
434
+ * enumeration or system prompt projection failures throw this so consuming
435
+ * routes can return 503 PI_RUNTIME_UNAVAILABLE instead of a fake-empty 200.
436
+ * Partial ResourceLoader / package-inventory failures are NOT critical — they
437
+ * keep the 200 snapshot and surface as structured diagnostics instead.
438
+ */
439
+ export class RuntimeSnapshotUnavailableError extends Error {
440
+ code = "PI_RUNTIME_UNAVAILABLE";
441
+ constructor(message) {
442
+ super(message);
443
+ this.name = "RuntimeSnapshotUnavailableError";
444
+ }
445
+ }
446
+ /**
447
+ * Browser-safe projection of a live runtime snapshot (design §2.2 / RF-03).
448
+ *
449
+ * Every browser-visible string runs the shared secret-scrub chain
450
+ * (scrubSecrets + ghp_/Bearer redaction via redactRuntimeText) and a fixed
451
+ * per-field length limit: 200 (names/spec/source/origin), 400 (paths/errors/
452
+ * diagnostic messages), 600 (skill descriptions), 80 (package versions);
453
+ * truncation uses the fixed `…[truncated N chars …]` marker. The server-only
454
+ * `raw` prompt and the RAW-based `sha256`/`characters` pass through untouched,
455
+ * and real empty lists stay empty.
456
+ *
457
+ * Pure + deterministic and exported so the route harness reuses the exact
458
+ * production implementation (same code path as getRuntimeSnapshot).
459
+ */
460
+ export function projectRuntimeSnapshot(input) {
461
+ const field = (value, max) => value === undefined ? undefined : redactRuntimeText(value, max);
462
+ const projectSkill = (skill) => ({
463
+ name: field(skill.name, 200) ?? "",
464
+ description: field(skill.description, 600) ?? "",
465
+ path: field(skill.path, 400) ?? "",
466
+ ...(skill.baseDir ? { baseDir: field(skill.baseDir, 400) } : {}),
467
+ scope: skill.scope,
468
+ source: field(skill.source, 200) ?? "",
469
+ enabled: skill.enabled,
470
+ diagnostics: skill.diagnostics.map((diag) => field(diag, 400) ?? ""),
471
+ });
472
+ const projectExtension = (ext) => ({
473
+ name: field(ext.name, 200) ?? "",
474
+ path: field(ext.path, 400) ?? "",
475
+ ...(ext.resolvedPath
476
+ ? { resolvedPath: field(ext.resolvedPath, 400) }
477
+ : {}),
478
+ source: field(ext.source, 200) ?? "",
479
+ scope: ext.scope,
480
+ ...(ext.origin ? { origin: field(ext.origin, 200) } : {}),
481
+ enabled: ext.enabled,
482
+ ...(ext.error ? { error: field(ext.error, 400) } : {}),
483
+ });
484
+ const projectPackage = (pkg) => ({
485
+ spec: field(pkg.spec, 200) ?? "",
486
+ scope: pkg.scope,
487
+ enabled: pkg.enabled,
488
+ ...(pkg.version ? { version: field(pkg.version, 80) } : {}),
489
+ ...(pkg.configuredVersion
490
+ ? { configuredVersion: field(pkg.configuredVersion, 80) }
491
+ : {}),
492
+ ...(pkg.packageName ? { packageName: field(pkg.packageName, 200) } : {}),
493
+ ...(pkg.path ? { path: field(pkg.path, 400) } : {}),
494
+ ...(pkg.installedPath ? { installedPath: field(pkg.installedPath, 400) } : {}),
495
+ ...(pkg.cwd ? { cwd: field(pkg.cwd, 400) } : {}),
496
+ resolved: pkg.resolved.map((resource) => ({
497
+ type: resource.type,
498
+ name: field(resource.name, 200) ?? "",
499
+ path: field(resource.path, 400) ?? "",
500
+ source: field(resource.source, 200) ?? "",
501
+ scope: resource.scope,
502
+ })),
503
+ diagnostics: pkg.diagnostics.map((diag) => field(diag, 400) ?? ""),
504
+ });
505
+ const projectDiagnostic = (diag) => ({
506
+ type: diag.type,
507
+ ...(diag.code ? { code: diag.code } : {}),
508
+ message: field(diag.message, 400) ?? "",
509
+ ...(diag.path ? { path: field(diag.path, 400) } : {}),
510
+ ...(diag.retryable !== undefined
511
+ ? { retryable: diag.retryable }
512
+ : {}),
513
+ });
514
+ return {
515
+ sessionId: input.sessionId,
516
+ revision: input.revision,
517
+ loadedAt: input.loadedAt,
518
+ selection: {
519
+ ...(input.selection.model
520
+ ? {
521
+ model: {
522
+ provider: input.selection.model.provider,
523
+ id: input.selection.model.id,
524
+ ...(input.selection.model.name
525
+ ? { name: field(input.selection.model.name, 200) }
526
+ : {}),
527
+ },
528
+ }
529
+ : {}),
530
+ thinkingSelection: input.selection.thinkingSelection,
531
+ ...(input.selection.effectiveThinkingLevel
532
+ ? { effectiveThinkingLevel: input.selection.effectiveThinkingLevel }
533
+ : {}),
534
+ },
535
+ models: input.models.map((model) => ({
536
+ provider: model.provider,
537
+ id: model.id,
538
+ ...(model.name ? { name: field(model.name, 200) } : {}),
539
+ ...(model.hasCredentials !== undefined
540
+ ? { hasCredentials: model.hasCredentials }
541
+ : {}),
542
+ })),
543
+ thinkingLevels: input.thinkingLevels.map((option) => ({ ...option })),
544
+ systemPrompt: {
545
+ ...(input.systemPrompt.raw !== undefined
546
+ ? { raw: input.systemPrompt.raw }
547
+ : {}),
548
+ text: redactRuntimeText(input.systemPrompt.text, RUNTIME_CONTEXT_TEXT_MAX),
549
+ sha256: input.systemPrompt.sha256,
550
+ characters: input.systemPrompt.characters,
551
+ },
552
+ activeTools: [...input.activeTools],
553
+ contextFiles: input.contextFiles.map((file) => ({
554
+ path: field(file.path, 400) ?? "",
555
+ characters: file.characters,
556
+ })),
557
+ skills: input.skills.map(projectSkill),
558
+ extensions: input.extensions.map(projectExtension),
559
+ packages: input.packages.map(projectPackage),
560
+ diagnostics: input.diagnostics.map(projectDiagnostic),
561
+ hasBash: input.hasBash,
562
+ };
563
+ }
385
564
  /**
386
565
  * Extract the latest assistant text fragment from an SDK message object.
387
566
  * Tolerant of multiple message shapes the SDK emits.
@@ -423,6 +602,33 @@ function readSessionManagerId(manager) {
423
602
  }
424
603
  return undefined;
425
604
  }
605
+ function readSessionManagerFile(manager) {
606
+ if (!manager || typeof manager !== "object")
607
+ return undefined;
608
+ const record = manager;
609
+ if (typeof record.getSessionFile === "function") {
610
+ try {
611
+ const value = record.getSessionFile();
612
+ if (typeof value === "string" && value.trim())
613
+ return value;
614
+ }
615
+ catch {
616
+ // ignore
617
+ }
618
+ }
619
+ if (typeof record.sessionFile === "string" && record.sessionFile.trim()) {
620
+ return record.sessionFile;
621
+ }
622
+ return undefined;
623
+ }
624
+ /** Structured materialization failure for dependent Chat endpoints. */
625
+ export class PiSessionInitFailedError extends Error {
626
+ code = "PI_SESSION_INIT_FAILED";
627
+ constructor(message) {
628
+ super(message);
629
+ this.name = "PiSessionInitFailedError";
630
+ }
631
+ }
426
632
  /**
427
633
  * Console Pi runtime — holds Chat sessions and drives prompt() with three-gate
428
634
  * tool enforcement. Session-per-sessionId; cross-request reuse via open().
@@ -433,9 +639,27 @@ export class ConsolePiRuntime {
433
639
  sessions = new Map();
434
640
  modelRuntimes = new Map();
435
641
  sessionManagers = new Map();
642
+ /** Per-session SDK service scope (resourceLoader / settingsManager) used by the live runtime snapshot. */
643
+ serviceScopes = new Map();
644
+ /** Snapshot revision: bumped on create / reopen / model / thinking / reload. */
645
+ revisions = new Map();
646
+ /** RF-01: per-session reload in-flight lock (concurrent second call → PI_SESSION_BUSY). */
647
+ reloadInFlight = new Set();
648
+ /** At most one detached automatic-title task per durable Console session. */
649
+ titleInFlight = new Set();
650
+ /** Composer thinking selection ("auto" = no explicit override). */
651
+ thinkingSelections = new Map();
436
652
  mainlineLeaves = new Map();
653
+ /** Per-sessionId single-flight materialization (create fast-path / readiness fence). */
654
+ sessionInit = new Map();
655
+ /** Resolved model binding after materialization (create may return before this is known). */
656
+ sessionModels = new Map();
657
+ /** SessionIds disposed while materialization was still in flight. */
658
+ disposedSessions = new Set();
659
+ /** Session-less tool definition caches (no AgentSession state). */
660
+ operatorCustomToolsPromise;
661
+ exploreCustomToolsPromise;
437
662
  bindings;
438
- disabledInstructionSkills = new Set();
439
663
  constructor(options) {
440
664
  this.options = options;
441
665
  this.loader = createOperatorChatResourceLoader();
@@ -461,9 +685,6 @@ export class ConsolePiRuntime {
461
685
  })),
462
686
  };
463
687
  }
464
- setDisabledInstructionSkills(names) {
465
- this.disabledInstructionSkills = new Set(names);
466
- }
467
688
  /** Operator action context used by custom-tool dispatchers (Gate 3). */
468
689
  get actionContext() {
469
690
  return this.options.actionContext;
@@ -472,18 +693,20 @@ export class ConsolePiRuntime {
472
693
  * Resolve the Chat default model descriptor from harness.json
473
694
  * executors.pi.MED + the SDK available-model list. Returns undefined when
474
695
  * harness is unset or no provider surfaces the model. No hardcoded fallback.
696
+ *
697
+ * Prefer reusing an already-built modelRuntime (same services object as
698
+ * formal materialization) so create does not construct throwaway services.
475
699
  */
476
- async resolveDefaultModel() {
700
+ async resolveDefaultModel(modelRuntime) {
477
701
  let available;
478
702
  try {
479
- // Build a throwaway services just to read modelRuntime.snapshot. This is
480
- // cheap (SDK caches); tests inject bindings that stub listAvailableModels.
481
- const { services } = await this.bindings.createServices({
482
- cwd: this.options.cwd,
483
- agentDir: await safeGetAgentDir(this.bindings),
484
- });
703
+ const runtime = modelRuntime ??
704
+ (await this.bindings.createServices({
705
+ cwd: this.options.cwd,
706
+ agentDir: await safeGetAgentDir(this.bindings),
707
+ })).services.modelRuntime;
485
708
  available = this.bindings.listAvailableModels({
486
- modelRuntime: services.modelRuntime,
709
+ modelRuntime: runtime,
487
710
  });
488
711
  }
489
712
  catch {
@@ -498,65 +721,120 @@ export class ConsolePiRuntime {
498
721
  };
499
722
  }
500
723
  /**
501
- * Load the operator-context instruction skills (plan D1) and compose the
502
- * read-only methodology fragment for the Chat system prompt. Returns an
503
- * empty string when no skills load (e.g. skills dir missing) so the
504
- * session still boots — skills are context, not a hard dependency.
724
+ * Fast create: durable SessionManager/JSONL shell first, then single-flight
725
+ * AgentSession materialization in the background. 201 may report
726
+ * `initializing: true`; dependent routes wait on the same promise.
505
727
  */
506
- async composeSystemPromptSkills() {
507
- try {
508
- const result = await loadOperatorChatInstructionSkills(this.options.skillsDir, OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS.filter((name) => !this.disabledInstructionSkills.has(name)));
509
- return composeInstructionSkillsPrompt(result.loaded);
728
+ async createSession(init) {
729
+ const sessionId = `operator-chat-${randomBytes(12).toString("hex")}`;
730
+ this.disposedSessions.delete(sessionId);
731
+ const sessionManager = await this.bindings.createSessionManager({
732
+ cwd: this.options.cwd,
733
+ sessionDir: this.options.sessionDir,
734
+ sessionId,
735
+ });
736
+ const sessionFile = readSessionManagerFile(sessionManager);
737
+ if (!sessionFile) {
738
+ throw new Error(`chat session shell missing sessionFile for ${sessionId}`);
510
739
  }
511
- catch (error) {
512
- process.stderr.write(`[console] chat instruction-skills load failed: ${error instanceof Error ? error.message : String(error)}\n`);
513
- return "";
740
+ this.sessionManagers.set(sessionId, sessionManager);
741
+ this.thinkingSelections.set(sessionId, "auto");
742
+ // Detached single-flight materialization; dependent routes await the same promise.
743
+ void this.startMaterialization(sessionId, {
744
+ sessionManager,
745
+ model: init?.model,
746
+ systemPromptSuffix: init?.systemPromptSuffix,
747
+ // Create path must resolve default model from the SAME services object
748
+ // used for formal materialization (no throwaway createServices).
749
+ resolveDefaultModel: !init?.model,
750
+ });
751
+ return {
752
+ sessionId,
753
+ sessionFile,
754
+ createdAt: new Date().toISOString(),
755
+ model: init?.model,
756
+ activeTools: OPERATOR_CHAT_ACTIVE_TOOL_NAMES(),
757
+ initializing: true,
758
+ };
759
+ }
760
+ /**
761
+ * Wait for single-flight materialization. Ready sessions resolve immediately;
762
+ * failed materialization throws PiSessionInitFailedError.
763
+ */
764
+ async ensureSessionReady(sessionId) {
765
+ const state = this.sessionInit.get(sessionId);
766
+ if (!state) {
767
+ if (this.sessions.has(sessionId))
768
+ return;
769
+ throw new Error(`chat session not found: ${sessionId}`);
770
+ }
771
+ if (state.status === "ready")
772
+ return;
773
+ if (state.status === "failed")
774
+ throw state.error;
775
+ await state.promise;
776
+ const next = this.sessionInit.get(sessionId);
777
+ if (next?.status === "failed")
778
+ throw next.error;
779
+ if (this.disposedSessions.has(sessionId) || !this.sessions.has(sessionId)) {
780
+ throw new Error(`chat session not found: ${sessionId}`);
514
781
  }
515
782
  }
516
- /** Create a new isolated Chat session pinned to the operator-chat tool set. */
517
- async createSession(init) {
518
- const sessionId = `operator-chat-${randomBytes(12).toString("hex")}`;
783
+ startMaterialization(sessionId, init) {
784
+ const existing = this.sessionInit.get(sessionId);
785
+ if (existing?.status === "initializing")
786
+ return existing.promise;
787
+ if (existing?.status === "ready")
788
+ return Promise.resolve();
789
+ if (existing?.status === "failed")
790
+ return Promise.reject(existing.error);
791
+ const promise = this.materializeSession(sessionId, init)
792
+ .then(() => {
793
+ if (this.disposedSessions.has(sessionId)) {
794
+ // Late materialization after delete: never register ready state.
795
+ this.sessionInit.delete(sessionId);
796
+ return;
797
+ }
798
+ this.sessionInit.set(sessionId, { status: "ready" });
799
+ })
800
+ .catch((error) => {
801
+ const failed = new PiSessionInitFailedError(error instanceof Error ? error.message : String(error));
802
+ if (!this.disposedSessions.has(sessionId)) {
803
+ this.sessionInit.set(sessionId, { status: "failed", error: failed });
804
+ }
805
+ else {
806
+ this.sessionInit.delete(sessionId);
807
+ }
808
+ // Observe rejection so detached create path has no unhandled rejection.
809
+ process.stderr.write(`[console] chat session init failed (${sessionId}): ${failed.message}\n`);
810
+ });
811
+ this.sessionInit.set(sessionId, { status: "initializing", promise });
812
+ return promise;
813
+ }
814
+ async materializeSession(sessionId, init) {
815
+ if (this.disposedSessions.has(sessionId))
816
+ return;
519
817
  const agentDir = await safeGetAgentDir(this.bindings);
520
- // Resolve default model from harness.json executors.pi.MED (no hardcode).
521
- // Caller may pass an explicit model; otherwise we read the repo harness and
522
- // search the SDK available-model list for a credentialled provider.
523
- let model = init?.model;
524
- if (!model) {
525
- const resolved = await this.resolveDefaultModel();
526
- if (resolved)
527
- model = resolved;
528
- }
529
- // Final fallback only when nothing resolved: let the SDK pick its default
530
- // (we do NOT hardcode a provider here).
531
- // Load operator-context instruction skills (plan D1) and compose the
532
- // read-only methodology fragment injected into the Chat system prompt.
533
- // Skills are NEVER registered as executable tools (D1 hard rule).
534
- const skillFragment = await this.composeSystemPromptSkills();
535
818
  const appendSystemPrompt = [
536
819
  OPERATOR_CHAT_SYSTEM_PROMPT_BASE,
537
- ...(skillFragment ? [skillFragment] : []),
538
- ...(init?.systemPromptSuffix ? [init.systemPromptSuffix] : []),
820
+ ...(init.systemPromptSuffix ? [init.systemPromptSuffix] : []),
539
821
  ];
822
+ // Single services construction for formal materialization (+ optional
823
+ // default-model resolve from the same modelRuntime).
540
824
  const { services } = await this.bindings.createServices({
541
825
  cwd: this.options.cwd,
542
826
  agentDir,
543
827
  resourceLoaderOptions: {
544
- // Closed surface: do not load user/project extensions, skills,
545
- // AGENTS.md, context files. The Chat tool surface is fixed by
546
- // the whitelist, not by filesystem discovery.
547
- noContextFiles: true,
548
- noSkills: true,
549
- noExtensions: true,
550
- // Inject the audited instruction skills as READ-ONLY context
551
- // (plan D1). appendSystemPrompt is the SDK's documented entry
552
- // point for adding system-prompt text without enabling tools.
553
828
  appendSystemPrompt,
554
829
  },
555
830
  });
831
+ if (this.disposedSessions.has(sessionId))
832
+ return;
556
833
  const modelRuntime = services.modelRuntime;
557
- // Resolve the concrete SDK model only when we have a descriptor. When no
558
- // model is resolved (harness unset + no snapshot match), we omit `model`
559
- // and let the SDK session use its own default (no hardcoded fallback).
834
+ let model = init.model;
835
+ if (!model && init.resolveDefaultModel) {
836
+ model = await this.resolveDefaultModel(modelRuntime);
837
+ }
560
838
  const resolvedModel = model
561
839
  ? (this.bindings.resolveModel({
562
840
  modelRuntime,
@@ -564,61 +842,13 @@ export class ConsolePiRuntime {
564
842
  modelId: model.modelId,
565
843
  }) ?? undefined)
566
844
  : undefined;
567
- const sessionManager = await this.bindings.createSessionManager({
568
- cwd: this.options.cwd,
569
- sessionDir: this.options.sessionDir,
570
- sessionId,
571
- });
572
- // Gate 1: pin the tool set at session create. assertNoWriteTool guards
573
- // that no file-WRITING coding tool slipped into the allowed list (bash
574
- // is intentionally allowed since the 2026-07-25 widening).
575
845
  assertNoWriteToolInList(OPERATOR_CHAT_ALLOWED_TOOLS);
576
- // Build custom Pi tools that route whitelisted operator actions into
577
- // the dispatcher (Gate 3 lives inside each tool's execute()).
578
- let operatorCustomTools = [];
579
- if (this.options.actionContext) {
580
- try {
581
- operatorCustomTools = await buildCustomOperatorTools({
582
- actionContext: this.options.actionContext,
583
- });
584
- }
585
- catch (error) {
586
- // If custom-tool build fails (e.g. typebox unavailable), fall back to
587
- // a tool-less session — Chat still works as text-only, and the
588
- // failure is surfaced via doctor / readiness diagnostics.
589
- process.stderr.write(`[console] chat custom-tools build failed: ${error instanceof Error ? error.message : String(error)}\n`);
590
- }
591
- }
592
- // M0-A: also register the safe explore tools (safe-read / safe-grep /
593
- // git-status / git-diff) so the model can probe the repo without bash.
594
- let exploreCustomTools = [];
595
- try {
596
- exploreCustomTools = await buildExploreCustomTools({
597
- repoRoot: this.options.cwd,
598
- });
599
- }
600
- catch (error) {
601
- process.stderr.write(`[console] chat explore-tools build failed: ${error instanceof Error ? error.message : String(error)}\n`);
602
- }
603
- const customTools = [...operatorCustomTools, ...exploreCustomTools];
604
- // Tool surface (roadmap M0):
605
- // - ALL model-callable operator actions are registered as customTools.
606
- // - Safe explore tools (safe-read / safe-grep / git-status / git-diff)
607
- // are also registered as customTools (bash is GONE — M0-A removes
608
- // the write-via-redirect escape).
609
- // - Built-in find/ls are registered by passing `tools:` (Gate 1). The
610
- // default builtin read/bash/edit/write are EXCLUDED: read/bash/grep
611
- // are replaced by the safe custom versions above; edit/write stay
612
- // excluded (file-writing never activated).
613
- // by passing `tools: OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS` (Gate 1).
614
- // This is REQUIRED: the SDK's default builtin set is only
615
- // `read, bash, edit, write` (grep/find/ls are NOT default builtins —
616
- // see SDK CreateAgentSessionOptions.tools docs). Without `tools`, the
846
+ const customTools = await this.loadCustomTools();
847
+ if (this.disposedSessions.has(sessionId))
848
+ return;
617
849
  const { session } = await this.bindings.createSessionFromServices({
618
850
  services,
619
- sessionManager,
620
- // Gate 1 (ADR 0011): full Pi builtins registered; only non-Pi write
621
- // channels excluded (apply_patch / full-tools / shell / coding-chat).
851
+ sessionManager: init.sessionManager,
622
852
  tools: [...OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS],
623
853
  excludeTools: [
624
854
  "apply_patch",
@@ -629,23 +859,82 @@ export class ConsolePiRuntime {
629
859
  "coding_chat",
630
860
  "shell",
631
861
  ],
632
- ...(customTools && customTools.length > 0 ? { customTools } : {}),
862
+ ...(customTools.length > 0 ? { customTools } : {}),
633
863
  ...(resolvedModel ? { model: resolvedModel } : {}),
634
864
  });
635
- // Gate 2: re-pin active tools (defensive — some SDK paths may seed
636
- // defaults). Active set = all operator actions + built-in explore tools.
637
- // File-writing tools are excluded above and thus cannot appear here.
638
- session.setActiveToolsByName(OPERATOR_CHAT_ACTIVE_TOOL_NAMES());
865
+ if (this.disposedSessions.has(sessionId)) {
866
+ try {
867
+ session.dispose();
868
+ }
869
+ catch {
870
+ // ignore late dispose cleanup
871
+ }
872
+ return;
873
+ }
874
+ session.setActiveToolsByName(computeOperatorChatActiveToolNames(session));
875
+ // Register then re-check dispose race: a concurrent dispose must win.
639
876
  this.sessions.set(sessionId, session);
640
- this.sessionManagers.set(sessionId, sessionManager);
877
+ this.sessionManagers.set(sessionId, init.sessionManager);
641
878
  this.modelRuntimes.set(sessionId, modelRuntime);
642
- return {
643
- sessionId,
644
- sessionFile: session.sessionFile,
645
- createdAt: new Date().toISOString(),
646
- model,
647
- activeTools: session.getActiveToolNames(),
648
- };
879
+ this.sessionModels.set(sessionId, model);
880
+ this.serviceScopes.set(sessionId, {
881
+ resourceLoader: services.resourceLoader,
882
+ settingsManager: services.settingsManager,
883
+ });
884
+ this.revisions.set(sessionId, 1);
885
+ if (!this.thinkingSelections.has(sessionId)) {
886
+ this.thinkingSelections.set(sessionId, "auto");
887
+ }
888
+ if (this.disposedSessions.has(sessionId)) {
889
+ try {
890
+ session.dispose();
891
+ }
892
+ catch {
893
+ // ignore
894
+ }
895
+ this.sessions.delete(sessionId);
896
+ this.sessionManagers.delete(sessionId);
897
+ this.modelRuntimes.delete(sessionId);
898
+ this.sessionModels.delete(sessionId);
899
+ this.serviceScopes.delete(sessionId);
900
+ this.revisions.delete(sessionId);
901
+ this.thinkingSelections.delete(sessionId);
902
+ }
903
+ }
904
+ /** Cached session-less custom tool definitions (operator + explore). */
905
+ async loadCustomTools() {
906
+ const [operatorCustomTools, exploreCustomTools] = await Promise.all([
907
+ this.loadOperatorCustomTools(),
908
+ this.loadExploreCustomTools(),
909
+ ]);
910
+ return [...operatorCustomTools, ...exploreCustomTools];
911
+ }
912
+ loadOperatorCustomTools() {
913
+ if (!this.options.actionContext)
914
+ return Promise.resolve([]);
915
+ if (!this.operatorCustomToolsPromise) {
916
+ this.operatorCustomToolsPromise = buildCustomOperatorTools({
917
+ actionContext: this.options.actionContext,
918
+ }).catch((error) => {
919
+ process.stderr.write(`[console] chat custom-tools build failed: ${error instanceof Error ? error.message : String(error)}\n`);
920
+ // Allow a later create/reopen to retry after a transient failure.
921
+ this.operatorCustomToolsPromise = undefined;
922
+ return [];
923
+ });
924
+ }
925
+ return this.operatorCustomToolsPromise;
926
+ }
927
+ loadExploreCustomTools() {
928
+ if (!this.exploreCustomToolsPromise) {
929
+ this.exploreCustomToolsPromise = buildExploreCustomTools({
930
+ repoRoot: this.options.cwd,
931
+ }).catch((error) => {
932
+ process.stderr.write(`[console] chat explore-tools build failed: ${error instanceof Error ? error.message : String(error)}\n`);
933
+ this.exploreCustomToolsPromise = undefined;
934
+ return [];
935
+ });
936
+ }
937
+ return this.exploreCustomToolsPromise;
649
938
  }
650
939
  async forkSession(init) {
651
940
  if (!this.bindings.forkSessionManager)
@@ -661,10 +950,15 @@ export class ConsolePiRuntime {
661
950
  sessionManager,
662
951
  model: init.model,
663
952
  });
664
- built.session.setActiveToolsByName(OPERATOR_CHAT_ACTIVE_TOOL_NAMES());
953
+ built.session.setActiveToolsByName(computeOperatorChatActiveToolNames(built.session));
665
954
  this.sessions.set(sessionId, built.session);
666
955
  this.sessionManagers.set(sessionId, sessionManager);
667
956
  this.modelRuntimes.set(sessionId, built.modelRuntime);
957
+ this.sessionModels.set(sessionId, init.model);
958
+ this.serviceScopes.set(sessionId, built.services);
959
+ this.revisions.set(sessionId, 1);
960
+ this.thinkingSelections.set(sessionId, "auto");
961
+ this.sessionInit.set(sessionId, { status: "ready" });
668
962
  return {
669
963
  sessionId,
670
964
  sessionFile: built.session.sessionFile,
@@ -709,12 +1003,14 @@ export class ConsolePiRuntime {
709
1003
  };
710
1004
  }
711
1005
  async listModels(sessionId) {
1006
+ await this.ensureSessionReady(sessionId);
712
1007
  const modelRuntime = this.modelRuntimes.get(sessionId);
713
1008
  if (!this.sessions.has(sessionId) || !modelRuntime)
714
1009
  throw new Error(`chat session not active: ${sessionId}`);
715
1010
  return this.bindings.listAvailableModels({ modelRuntime });
716
1011
  }
717
1012
  async applyModel(sessionId, model) {
1013
+ await this.ensureSessionReady(sessionId);
718
1014
  const session = this.sessions.get(sessionId);
719
1015
  const modelRuntime = this.modelRuntimes.get(sessionId);
720
1016
  if (!session || !modelRuntime)
@@ -729,26 +1025,46 @@ export class ConsolePiRuntime {
729
1025
  if (!resolved)
730
1026
  throw new Error("MODEL_NOT_AVAILABLE");
731
1027
  await session.setModel(resolved);
1028
+ this.bumpRevision(sessionId);
732
1029
  return {
733
1030
  provider: session.model?.provider ?? model.provider,
734
1031
  modelId: session.model?.id ?? session.model?.modelId ?? model.modelId,
735
1032
  };
736
1033
  }
1034
+ /**
1035
+ * Apply a thinking-level selection. `auto` records the selection WITHOUT
1036
+ * sending any explicit override (the SDK keeps its own default); an explicit
1037
+ * level calls `session.setThinkingLevel`. Returns the effective value.
1038
+ */
737
1039
  applyThinkingLevel(sessionId, level) {
1040
+ const state = this.sessionInit.get(sessionId);
1041
+ if (state?.status === "failed")
1042
+ throw state.error;
1043
+ if (state?.status === "initializing") {
1044
+ throw new Error(`chat session not active: ${sessionId}`);
1045
+ }
738
1046
  const session = this.sessions.get(sessionId);
739
1047
  if (!session)
740
1048
  throw new Error(`chat session not active: ${sessionId}`);
1049
+ if (level === "auto") {
1050
+ this.thinkingSelections.set(sessionId, "auto");
1051
+ this.bumpRevision(sessionId);
1052
+ return "auto";
1053
+ }
741
1054
  if (!THINKING_LEVELS.includes(level) || !session.setThinkingLevel)
742
1055
  throw new Error("THINKING_LEVEL_UNSUPPORTED");
743
1056
  session.setThinkingLevel(level);
1057
+ this.thinkingSelections.set(sessionId, level);
1058
+ this.bumpRevision(sessionId);
744
1059
  return session.thinkingLevel ?? level;
745
1060
  }
746
1061
  getRuntimeSelection(sessionId) {
747
1062
  const session = this.sessions.get(sessionId);
748
1063
  if (!session)
749
1064
  return { activeTools: [] };
750
- const provider = session.model?.provider;
751
- const modelId = session.model?.id ?? session.model?.modelId;
1065
+ const stored = this.sessionModels.get(sessionId);
1066
+ const provider = session.model?.provider ?? stored?.provider;
1067
+ const modelId = session.model?.id ?? session.model?.modelId ?? stored?.modelId;
752
1068
  return {
753
1069
  ...(provider && modelId ? { model: { provider, modelId } } : {}),
754
1070
  ...(session.thinkingLevel
@@ -757,6 +1073,393 @@ export class ConsolePiRuntime {
757
1073
  activeTools: session.getActiveToolNames(),
758
1074
  };
759
1075
  }
1076
+ bumpRevision(sessionId) {
1077
+ this.revisions.set(sessionId, (this.revisions.get(sessionId) ?? 0) + 1);
1078
+ }
1079
+ /**
1080
+ * Resolve the RAW system prompt actually injected into the Pi session
1081
+ * (design §2.2 / AC-03). Fallback chain: agent.state.systemPrompt →
1082
+ * session.systemPrompt getter → ResourceLoader.getSystemPrompt() → the
1083
+ * composed operator-chat base prompt. Never returns empty.
1084
+ */
1085
+ async getRuntimeSystemPrompt(sessionId) {
1086
+ await this.ensureSessionReady(sessionId).catch(() => undefined);
1087
+ const session = this.sessions.get(sessionId);
1088
+ if (!session)
1089
+ return composeOperatorChatSystemPrompt({});
1090
+ const fromState = session.state?.systemPrompt;
1091
+ const fromGetter = session.systemPrompt;
1092
+ const loader = this.serviceScopes.get(sessionId)?.resourceLoader;
1093
+ return (fromState ||
1094
+ fromGetter ||
1095
+ loader?.getSystemPrompt?.() ||
1096
+ composeOperatorChatSystemPrompt({}));
1097
+ }
1098
+ /**
1099
+ * Read the RAW system prompt for the snapshot, converting any projection
1100
+ * failure into the structured critical error (RF-02): a broken system prompt
1101
+ * source must fail the whole snapshot with 503 PI_RUNTIME_UNAVAILABLE rather
1102
+ * than silently fabricate prompt text.
1103
+ */
1104
+ async readRawSystemPrompt(sessionId) {
1105
+ try {
1106
+ return await this.getRuntimeSystemPrompt(sessionId);
1107
+ }
1108
+ catch (error) {
1109
+ throw new RuntimeSnapshotUnavailableError(`system prompt projection failed: ${error instanceof Error ? error.message : String(error)}`);
1110
+ }
1111
+ }
1112
+ /**
1113
+ * Official Pi session reload (AC-05 / AC-06). Rejects with a structured
1114
+ * code while the session is busy (streaming / compacting / bash in flight /
1115
+ * duplicate reload) — never aborts, never silently queues.
1116
+ */
1117
+ async reloadSession(sessionId) {
1118
+ try {
1119
+ await this.ensureSessionReady(sessionId);
1120
+ }
1121
+ catch (error) {
1122
+ if (error instanceof PiSessionInitFailedError) {
1123
+ return { ok: false, code: error.code, message: error.message };
1124
+ }
1125
+ return {
1126
+ ok: false,
1127
+ code: "PI_SESSION_NOT_FOUND",
1128
+ message: error instanceof Error ? error.message : String(error),
1129
+ };
1130
+ }
1131
+ const session = this.sessions.get(sessionId);
1132
+ if (!session) {
1133
+ return {
1134
+ ok: false,
1135
+ code: "PI_SESSION_NOT_FOUND",
1136
+ message: `chat session not active: ${sessionId}`,
1137
+ };
1138
+ }
1139
+ // RF2-03: the per-sessionId in-flight lock is checked FIRST — before the
1140
+ // streaming/compacting/bash/support checks. JS runs check-then-add in one
1141
+ // synchronous segment, so two concurrent calls cannot both pass; the lock
1142
+ // is released in `finally` on BOTH the success and the error path, and
1143
+ // different sessions never block each other. Priority matters: the first
1144
+ // reload() may synchronously flip isIdle/isCompacting/isBashRunning/
1145
+ // hasPendingBashMessages to busy, and a second concurrent call must still
1146
+ // report the reload-in-flight busy (not the streaming/compacting one).
1147
+ if (this.reloadInFlight.has(sessionId)) {
1148
+ return {
1149
+ ok: false,
1150
+ code: "PI_SESSION_BUSY",
1151
+ message: "session is busy (reload in flight); reload rejected without abort",
1152
+ };
1153
+ }
1154
+ const busyReasons = [];
1155
+ if (session.isStreaming === true || session.isIdle === false)
1156
+ busyReasons.push("streaming");
1157
+ if (session.isCompacting === true)
1158
+ busyReasons.push("compacting");
1159
+ if (session.isBashRunning === true)
1160
+ busyReasons.push("bash running");
1161
+ if (session.hasPendingBashMessages === true)
1162
+ busyReasons.push("bash messages pending");
1163
+ if (busyReasons.length > 0) {
1164
+ return {
1165
+ ok: false,
1166
+ code: "PI_SESSION_BUSY",
1167
+ message: `session is busy (${busyReasons.join(", ")}); reload rejected without abort`,
1168
+ };
1169
+ }
1170
+ if (typeof session.reload !== "function") {
1171
+ return {
1172
+ ok: false,
1173
+ code: "PI_RELOAD_FAILED",
1174
+ message: "this Pi runtime does not support session.reload()",
1175
+ };
1176
+ }
1177
+ this.reloadInFlight.add(sessionId);
1178
+ try {
1179
+ await session.reload();
1180
+ }
1181
+ catch (error) {
1182
+ return {
1183
+ ok: false,
1184
+ code: "PI_RELOAD_FAILED",
1185
+ message: error instanceof Error ? error.message : String(error),
1186
+ };
1187
+ }
1188
+ finally {
1189
+ this.reloadInFlight.delete(sessionId);
1190
+ }
1191
+ this.bumpRevision(sessionId);
1192
+ return { ok: true, revision: this.revisions.get(sessionId) ?? 0 };
1193
+ }
1194
+ /**
1195
+ * Project the unified live runtime snapshot (design §2.2). The current Pi
1196
+ * session is the single source of truth: real system prompt, real model /
1197
+ * thinking selection (auto + effective), the session modelRuntime model
1198
+ * list, and the session ResourceLoader inventories. Undefined when the
1199
+ * session is not active — callers fail closed instead of fabricating data.
1200
+ */
1201
+ async getRuntimeSnapshot(sessionId, options) {
1202
+ try {
1203
+ await this.ensureSessionReady(sessionId);
1204
+ }
1205
+ catch (error) {
1206
+ if (error instanceof PiSessionInitFailedError)
1207
+ throw error;
1208
+ // Inactive / unknown session: fail closed with undefined (no fabricated snapshot).
1209
+ return undefined;
1210
+ }
1211
+ const session = this.sessions.get(sessionId);
1212
+ if (!session)
1213
+ return undefined;
1214
+ const services = this.serviceScopes.get(sessionId);
1215
+ const modelRuntime = this.modelRuntimes.get(sessionId);
1216
+ const resourceLoader = services?.resourceLoader;
1217
+ const settingsManager = services?.settingsManager;
1218
+ const thinkingSelection = this.thinkingSelections.get(sessionId) ?? "auto";
1219
+ const effectiveThinkingLevel = session.thinkingLevel ??
1220
+ settingsManager?.getDefaultThinkingLevel?.() ??
1221
+ (thinkingSelection === "auto" ? undefined : thinkingSelection);
1222
+ const rawSystemPrompt = await this.readRawSystemPrompt(sessionId);
1223
+ // Models come from the session modelRuntime. A model-enumeration failure
1224
+ // is a CRITICAL projection failure (RF-02): it throws
1225
+ // RuntimeSnapshotUnavailableError so consuming routes return 503
1226
+ // PI_RUNTIME_UNAVAILABLE — never a fabricated empty model list.
1227
+ let models = [];
1228
+ if (modelRuntime) {
1229
+ try {
1230
+ models = this.bindings
1231
+ .listAvailableModels({ modelRuntime })
1232
+ .map((entry) => ({
1233
+ provider: entry.provider,
1234
+ id: entry.id,
1235
+ ...(entry.name ? { name: entry.name } : {}),
1236
+ hasCredentials: entry.hasCredentials,
1237
+ }));
1238
+ }
1239
+ catch (error) {
1240
+ throw new RuntimeSnapshotUnavailableError(`model enumeration failed: ${error instanceof Error ? error.message : String(error)}`);
1241
+ }
1242
+ }
1243
+ // Thinking options: auto is always offered; explicit levels only when the
1244
+ // runtime session supports setThinkingLevel (xhigh/max ship with the SDK
1245
+ // and therefore surface only when this runtime provides them).
1246
+ const supportedLevels = typeof session.setThinkingLevel === "function"
1247
+ ? [...THINKING_LEVELS]
1248
+ : [];
1249
+ const thinkingLevels = [
1250
+ { value: "auto" },
1251
+ ...supportedLevels.map((value) => ({ value })),
1252
+ ];
1253
+ // Resource inventories from the session ResourceLoader (ADR 0012 open
1254
+ // surface). Missing loader → real empty lists, never fabricated entries.
1255
+ // Each subface is isolated (RF-02): a throwing subface keeps the other
1256
+ // inventories AND surfaces a structured retryable diagnostic instead of
1257
+ // silently dropping the whole resource projection.
1258
+ const contextFiles = [];
1259
+ const skills = [];
1260
+ const extensions = [];
1261
+ const diagnostics = [];
1262
+ let resolvedResources = [];
1263
+ const resourceFailure = (subface, error) => {
1264
+ diagnostics.push({
1265
+ type: "error",
1266
+ code: "RESOURCE_LOAD_FAILED",
1267
+ message: `${subface} inventory failed: ${error instanceof Error ? error.message : String(error)}`,
1268
+ retryable: true,
1269
+ });
1270
+ };
1271
+ if (resourceLoader) {
1272
+ try {
1273
+ const agentsFiles = resourceLoader.getAgentsFiles?.();
1274
+ for (const file of agentsFiles?.agentsFiles ?? []) {
1275
+ if (!file.path)
1276
+ continue;
1277
+ contextFiles.push({
1278
+ path: file.path,
1279
+ characters: file.content?.length ?? 0,
1280
+ });
1281
+ }
1282
+ }
1283
+ catch (error) {
1284
+ resourceFailure("context files", error);
1285
+ }
1286
+ try {
1287
+ const skillsResult = resourceLoader.getSkills?.();
1288
+ for (const skill of skillsResult?.skills ?? []) {
1289
+ skills.push({
1290
+ name: skill.name,
1291
+ description: skill.description ?? "",
1292
+ path: skill.filePath,
1293
+ ...(skill.baseDir ? { baseDir: skill.baseDir } : {}),
1294
+ scope: skill.sourceInfo?.scope ?? "unknown",
1295
+ source: skill.sourceInfo?.source ?? "",
1296
+ enabled: !skill.disableModelInvocation,
1297
+ diagnostics: [],
1298
+ });
1299
+ resolvedResources.push({
1300
+ type: "skill",
1301
+ name: skill.name,
1302
+ path: skill.filePath,
1303
+ source: skill.sourceInfo?.source ?? "",
1304
+ scope: skill.sourceInfo?.scope ?? "unknown",
1305
+ });
1306
+ }
1307
+ for (const diag of skillsResult?.diagnostics ?? []) {
1308
+ if (!diag?.message)
1309
+ continue;
1310
+ diagnostics.push({
1311
+ type: diag.type ?? "warning",
1312
+ message: diag.message,
1313
+ ...(diag.path ? { path: diag.path } : {}),
1314
+ });
1315
+ }
1316
+ }
1317
+ catch (error) {
1318
+ resourceFailure("skills", error);
1319
+ }
1320
+ try {
1321
+ const extResult = resourceLoader.getExtensions?.();
1322
+ for (const ext of extResult?.extensions ?? []) {
1323
+ const path = ext.resolvedPath ?? ext.path ?? "";
1324
+ extensions.push({
1325
+ name: ext.sourceInfo?.source ?? ext.path ?? "extension",
1326
+ path,
1327
+ ...(ext.resolvedPath ? { resolvedPath: ext.resolvedPath } : {}),
1328
+ source: ext.sourceInfo?.source ?? "",
1329
+ scope: ext.sourceInfo?.scope ?? "unknown",
1330
+ ...(ext.sourceInfo?.origin ? { origin: ext.sourceInfo.origin } : {}),
1331
+ enabled: true,
1332
+ });
1333
+ resolvedResources.push({
1334
+ type: "extension",
1335
+ name: ext.sourceInfo?.source ?? ext.path ?? "extension",
1336
+ path,
1337
+ source: ext.sourceInfo?.source ?? "",
1338
+ scope: ext.sourceInfo?.scope ?? "unknown",
1339
+ });
1340
+ }
1341
+ for (const err of extResult?.errors ?? []) {
1342
+ if (!err?.error)
1343
+ continue;
1344
+ diagnostics.push({
1345
+ type: "error",
1346
+ message: err.error,
1347
+ ...(err.path ? { path: err.path } : {}),
1348
+ });
1349
+ }
1350
+ }
1351
+ catch (error) {
1352
+ resourceFailure("extensions", error);
1353
+ }
1354
+ try {
1355
+ const promptsResult = resourceLoader.getPrompts?.();
1356
+ for (const prompt of promptsResult?.prompts ?? []) {
1357
+ resolvedResources.push({
1358
+ type: "prompt",
1359
+ name: prompt.name,
1360
+ path: prompt.filePath,
1361
+ source: prompt.sourceInfo?.source ?? "",
1362
+ scope: prompt.sourceInfo?.scope ?? "unknown",
1363
+ });
1364
+ }
1365
+ for (const diag of promptsResult?.diagnostics ?? []) {
1366
+ if (!diag?.message)
1367
+ continue;
1368
+ diagnostics.push({
1369
+ type: diag.type ?? "warning",
1370
+ message: diag.message,
1371
+ ...(diag.path ? { path: diag.path } : {}),
1372
+ });
1373
+ }
1374
+ }
1375
+ catch (error) {
1376
+ resourceFailure("prompts", error);
1377
+ }
1378
+ try {
1379
+ const themesResult = resourceLoader.getThemes?.();
1380
+ for (const theme of themesResult?.themes ?? []) {
1381
+ if (!theme.path)
1382
+ continue;
1383
+ resolvedResources.push({
1384
+ type: "theme",
1385
+ name: theme.name ?? theme.path,
1386
+ path: theme.path,
1387
+ source: "",
1388
+ scope: "unknown",
1389
+ });
1390
+ }
1391
+ for (const diag of themesResult?.diagnostics ?? []) {
1392
+ if (!diag?.message)
1393
+ continue;
1394
+ diagnostics.push({
1395
+ type: diag.type ?? "warning",
1396
+ message: diag.message,
1397
+ ...(diag.path ? { path: diag.path } : {}),
1398
+ });
1399
+ }
1400
+ }
1401
+ catch (error) {
1402
+ resourceFailure("themes", error);
1403
+ }
1404
+ }
1405
+ // Packages: Pi settings specs + resolved resource metadata. Fields the SDK
1406
+ // does not expose stay absent (real missing, never fabricated). A
1407
+ // readPackageInventory failure is a PARTIAL failure (RF-02): the snapshot
1408
+ // stays 200 with a structured retryable diagnostic — never a silent
1409
+ // fake-empty package list.
1410
+ const { readPackageInventory } = await import("./pi-console-config.js");
1411
+ let packages = [];
1412
+ try {
1413
+ packages = (await readPackageInventory(await safeGetAgentDir(this.bindings), this.options.cwd, resolvedResources));
1414
+ }
1415
+ catch (error) {
1416
+ packages = [];
1417
+ diagnostics.push({
1418
+ type: "error",
1419
+ code: "PACKAGE_INVENTORY_FAILED",
1420
+ message: error instanceof Error ? error.message : String(error),
1421
+ retryable: true,
1422
+ });
1423
+ }
1424
+ return projectRuntimeSnapshot({
1425
+ sessionId,
1426
+ revision: this.revisions.get(sessionId) ?? 0,
1427
+ loadedAt: new Date().toISOString(),
1428
+ selection: {
1429
+ ...(session.model?.provider &&
1430
+ (session.model?.id ?? session.model?.modelId)
1431
+ ? {
1432
+ model: {
1433
+ provider: session.model.provider,
1434
+ id: session.model.id ?? session.model.modelId,
1435
+ ...(session.model.name ? { name: session.model.name } : {}),
1436
+ },
1437
+ }
1438
+ : {}),
1439
+ thinkingSelection,
1440
+ ...(effectiveThinkingLevel
1441
+ ? { effectiveThinkingLevel: effectiveThinkingLevel }
1442
+ : {}),
1443
+ },
1444
+ models,
1445
+ thinkingLevels,
1446
+ systemPrompt: {
1447
+ ...(options?.includeRaw ? { raw: rawSystemPrompt } : {}),
1448
+ // The RAW prompt flows into the projection: text is scrubbed +
1449
+ // length-limited while sha256/characters stay RAW-based (design §2.2).
1450
+ text: rawSystemPrompt,
1451
+ sha256: createHash("sha256").update(rawSystemPrompt).digest("hex"),
1452
+ characters: rawSystemPrompt.length,
1453
+ },
1454
+ activeTools: session.getActiveToolNames(),
1455
+ contextFiles,
1456
+ skills,
1457
+ extensions,
1458
+ packages,
1459
+ diagnostics,
1460
+ hasBash: true,
1461
+ });
1462
+ }
760
1463
  /**
761
1464
  * Re-open a previously persisted Chat session after Console restart or
762
1465
  * browser refresh (roadmap M1-S04 / S05).
@@ -772,6 +1475,24 @@ export class ConsolePiRuntime {
772
1475
  */
773
1476
  async reopenSession(init) {
774
1477
  const sessionId = init.sessionId;
1478
+ // Wait for in-flight create materialization rather than racing reopen.
1479
+ const initState = this.sessionInit.get(sessionId);
1480
+ if (initState?.status === "initializing") {
1481
+ try {
1482
+ await initState.promise;
1483
+ }
1484
+ catch {
1485
+ // failure recorded on sessionInit
1486
+ }
1487
+ }
1488
+ const failed = this.sessionInit.get(sessionId);
1489
+ if (failed?.status === "failed") {
1490
+ return {
1491
+ ok: false,
1492
+ code: "PI_SESSION_INIT_FAILED",
1493
+ message: failed.error.message,
1494
+ };
1495
+ }
775
1496
  // Already active in-memory: idempotent reopen is a no-op success.
776
1497
  if (this.sessions.has(sessionId)) {
777
1498
  return { ok: true, handle: await this.snapshotActive(sessionId) };
@@ -838,10 +1559,15 @@ export class ConsolePiRuntime {
838
1559
  message: `reopened Pi sessionId mismatch: expected ${sessionId}, got ${reportedId}`,
839
1560
  };
840
1561
  }
841
- session.setActiveToolsByName(OPERATOR_CHAT_ACTIVE_TOOL_NAMES());
1562
+ session.setActiveToolsByName(computeOperatorChatActiveToolNames(session));
842
1563
  this.sessions.set(sessionId, session);
843
1564
  this.sessionManagers.set(sessionId, sessionManager);
844
1565
  this.modelRuntimes.set(sessionId, built.modelRuntime);
1566
+ this.sessionModels.set(sessionId, init.model);
1567
+ this.serviceScopes.set(sessionId, built.services);
1568
+ this.revisions.set(sessionId, 1);
1569
+ this.thinkingSelections.set(sessionId, "auto");
1570
+ this.sessionInit.set(sessionId, { status: "ready" });
845
1571
  return {
846
1572
  ok: true,
847
1573
  handle: {
@@ -864,35 +1590,40 @@ export class ConsolePiRuntime {
864
1590
  /** Build a handle for an already-active session (idempotent reopen path). */
865
1591
  async snapshotActive(sessionId) {
866
1592
  const session = this.sessions.get(sessionId);
1593
+ const stored = this.sessionModels.get(sessionId);
1594
+ const fromSession = session.model?.provider
1595
+ ? {
1596
+ provider: session.model.provider,
1597
+ modelId: session.model.id ??
1598
+ session.model.modelId ??
1599
+ "",
1600
+ }
1601
+ : undefined;
1602
+ const model = stored ??
1603
+ (fromSession?.modelId ? fromSession : undefined);
867
1604
  return {
868
1605
  sessionId,
869
1606
  sessionFile: session.sessionFile,
870
1607
  createdAt: new Date().toISOString(),
871
- model: undefined,
1608
+ model,
872
1609
  activeTools: session.getActiveToolNames(),
873
1610
  };
874
1611
  }
875
1612
  /**
876
- * Shared services + pinned-tool materialization for both createSession and
877
- * reopenSession. Loads closed-surface services, resolves the model, builds
878
- * the operator + safe explore customTools, and calls the SDK with the same
879
- * excludeTools / tools policy (M0-A three-gate).
1613
+ * Shared services + pinned-tool materialization for reopen/fork paths.
1614
+ * Create uses startMaterialization instead so the durable shell can return
1615
+ * before AgentSession is ready. Tool definitions reuse runtime-level caches.
880
1616
  */
881
1617
  async buildSessionWithServices(init) {
882
1618
  const agentDir = await safeGetAgentDir(this.bindings);
883
- const skillFragment = await this.composeSystemPromptSkills();
884
1619
  const appendSystemPrompt = [
885
1620
  OPERATOR_CHAT_SYSTEM_PROMPT_BASE,
886
- ...(skillFragment ? [skillFragment] : []),
887
1621
  ...(init.systemPromptSuffix ? [init.systemPromptSuffix] : []),
888
1622
  ];
889
1623
  const { services } = await this.bindings.createServices({
890
1624
  cwd: this.options.cwd,
891
1625
  agentDir,
892
1626
  resourceLoaderOptions: {
893
- noContextFiles: true,
894
- noSkills: true,
895
- noExtensions: true,
896
1627
  appendSystemPrompt,
897
1628
  },
898
1629
  });
@@ -905,27 +1636,7 @@ export class ConsolePiRuntime {
905
1636
  }) ?? undefined)
906
1637
  : undefined;
907
1638
  assertNoWriteToolInList(OPERATOR_CHAT_ALLOWED_TOOLS);
908
- let operatorCustomTools = [];
909
- if (this.options.actionContext) {
910
- try {
911
- operatorCustomTools = await buildCustomOperatorTools({
912
- actionContext: this.options.actionContext,
913
- });
914
- }
915
- catch (error) {
916
- process.stderr.write(`[console] chat custom-tools build failed: ${error instanceof Error ? error.message : String(error)}\n`);
917
- }
918
- }
919
- let exploreCustomTools = [];
920
- try {
921
- exploreCustomTools = await buildExploreCustomTools({
922
- repoRoot: this.options.cwd,
923
- });
924
- }
925
- catch (error) {
926
- process.stderr.write(`[console] chat explore-tools build failed: ${error instanceof Error ? error.message : String(error)}\n`);
927
- }
928
- const customTools = [...operatorCustomTools, ...exploreCustomTools];
1639
+ const customTools = await this.loadCustomTools();
929
1640
  const { session } = await this.bindings.createSessionFromServices({
930
1641
  services,
931
1642
  sessionManager: init.sessionManager,
@@ -939,10 +1650,17 @@ export class ConsolePiRuntime {
939
1650
  "coding_chat",
940
1651
  "shell",
941
1652
  ],
942
- ...(customTools && customTools.length > 0 ? { customTools } : {}),
1653
+ ...(customTools.length > 0 ? { customTools } : {}),
943
1654
  ...(resolvedModel ? { model: resolvedModel } : {}),
944
1655
  });
945
- return { session, modelRuntime };
1656
+ return {
1657
+ session,
1658
+ modelRuntime,
1659
+ services: {
1660
+ resourceLoader: services.resourceLoader,
1661
+ settingsManager: services.settingsManager,
1662
+ },
1663
+ };
946
1664
  }
947
1665
  /** Re-open a previously created session (cross-request). */
948
1666
  open(sessionId) {
@@ -957,17 +1675,20 @@ export class ConsolePiRuntime {
957
1675
  * (the dispatcher is wired by the HTTP layer, see chat-session.ts).
958
1676
  */
959
1677
  async prompt(sessionId, text, onEvent, options) {
1678
+ // First message while creating waits on the same materialization promise.
1679
+ await this.ensureSessionReady(sessionId);
960
1680
  const session = this.sessions.get(sessionId);
961
1681
  if (!session) {
962
1682
  throw new Error(`chat session not found: ${sessionId}`);
963
1683
  }
964
1684
  // Gate 2 (re-pin before each turn): interview turns pass through the
965
1685
  // existing closed interview allow/deny registry. The next ordinary turn
966
- // explicitly restores the normal Operator Chat set.
1686
+ // explicitly restores the normal Operator Chat set (baseline + extension
1687
+ // tools, ADR 0012).
967
1688
  const requestedTools = OPERATOR_CHAT_ACTIVE_TOOL_NAMES();
968
1689
  const activeTools = options?.mode === "requirement-interview"
969
1690
  ? filterActiveInterviewTools(requestedTools).allowed
970
- : requestedTools;
1691
+ : computeOperatorChatActiveToolNames(session);
971
1692
  session.setActiveToolsByName(activeTools);
972
1693
  const unsub = session.subscribe((event) => {
973
1694
  const mapped = mapSdkEvent(sessionId, event);
@@ -1025,6 +1746,92 @@ export class ConsolePiRuntime {
1025
1746
  unsub();
1026
1747
  }
1027
1748
  }
1749
+ /** Generate exactly one title in an isolated in-memory, tool-less LOW session. */
1750
+ async generateTitle(sessionId, firstUserText) {
1751
+ if (this.titleInFlight.has(sessionId))
1752
+ return undefined;
1753
+ this.titleInFlight.add(sessionId);
1754
+ let session;
1755
+ let unsubscribe;
1756
+ let timeout;
1757
+ try {
1758
+ const { services } = await this.bindings.createServices({
1759
+ cwd: this.options.cwd,
1760
+ agentDir: await safeGetAgentDir(this.bindings),
1761
+ // The detached title worker has a closed title-only resource surface:
1762
+ // no project/user context, extensions, skills, prompt templates, or
1763
+ // themes. Its sole system prompt is deliberately minimal; never append
1764
+ // the operator-chat prompt or any loaded resource text.
1765
+ resourceLoaderOptions: {
1766
+ noContextFiles: true,
1767
+ noSkills: true,
1768
+ noExtensions: true,
1769
+ noPromptTemplates: true,
1770
+ noThemes: true,
1771
+ systemPrompt: "Generate one concise plain-text session title in the user's primary language. Return only the title.",
1772
+ },
1773
+ });
1774
+ const low = await resolveLowChatModel(this.options.cwd, this.bindings.listAvailableModels({ modelRuntime: services.modelRuntime }));
1775
+ if (!low)
1776
+ return undefined;
1777
+ const model = this.bindings.resolveModel({
1778
+ modelRuntime: services.modelRuntime,
1779
+ provider: low.provider,
1780
+ modelId: low.modelId,
1781
+ });
1782
+ if (!model)
1783
+ return undefined;
1784
+ if (!this.bindings.createInMemorySessionManager)
1785
+ return undefined;
1786
+ const sessionManager = await this.bindings.createInMemorySessionManager({
1787
+ cwd: this.options.cwd,
1788
+ });
1789
+ const created = await this.bindings.createSessionFromServices({
1790
+ services,
1791
+ sessionManager,
1792
+ noTools: "all",
1793
+ tools: [],
1794
+ customTools: [],
1795
+ model,
1796
+ });
1797
+ session = created.session;
1798
+ let output = "";
1799
+ unsubscribe = session.subscribe((event) => {
1800
+ if (event.type === "message_end")
1801
+ output = extractAssistantText(event.message);
1802
+ });
1803
+ const controller = new AbortController();
1804
+ timeout = setTimeout(() => controller.abort(), 10_000);
1805
+ await Promise.race([
1806
+ session.prompt(`User message:\n${firstUserText.slice(0, 1200)}`, { signal: controller.signal }),
1807
+ new Promise((_, reject) => controller.signal.addEventListener("abort", () => reject(new Error("TITLE_TIMEOUT")), { once: true })),
1808
+ ]);
1809
+ return output;
1810
+ }
1811
+ catch {
1812
+ return undefined;
1813
+ }
1814
+ finally {
1815
+ if (timeout)
1816
+ clearTimeout(timeout);
1817
+ unsubscribe?.();
1818
+ try {
1819
+ await session?.abort?.();
1820
+ }
1821
+ catch { /* abort converges before disposal */ }
1822
+ try {
1823
+ session?.dispose();
1824
+ }
1825
+ catch { /* isolated title cleanup is best-effort */ }
1826
+ this.titleInFlight.delete(sessionId);
1827
+ }
1828
+ }
1829
+ /** Pi session_info mirror after durable automatic-title persistence.
1830
+ * Callers own the detached catch boundary so synchronous throws and rejected
1831
+ * mirror Promises are both observed without affecting durable Chat state. */
1832
+ async setSessionName(sessionId, title) {
1833
+ await this.sessions.get(sessionId)?.setSessionName?.(title);
1834
+ }
1028
1835
  async compact(sessionId, customInstructions) {
1029
1836
  const session = this.sessions.get(sessionId);
1030
1837
  if (!session)
@@ -1037,6 +1844,9 @@ export class ConsolePiRuntime {
1037
1844
  return projectCompactSnapshot(result ?? {});
1038
1845
  }
1039
1846
  dispose(sessionId) {
1847
+ // Mark disposed first so a late materialization never registers ready.
1848
+ this.disposedSessions.add(sessionId);
1849
+ this.sessionInit.delete(sessionId);
1040
1850
  const session = this.sessions.get(sessionId);
1041
1851
  if (session) {
1042
1852
  try {
@@ -1045,11 +1855,16 @@ export class ConsolePiRuntime {
1045
1855
  catch {
1046
1856
  // ignore
1047
1857
  }
1048
- this.sessions.delete(sessionId);
1049
- this.sessionManagers.delete(sessionId);
1050
- this.mainlineLeaves.delete(sessionId);
1051
- this.modelRuntimes.delete(sessionId);
1052
1858
  }
1859
+ this.sessions.delete(sessionId);
1860
+ this.sessionManagers.delete(sessionId);
1861
+ this.mainlineLeaves.delete(sessionId);
1862
+ this.modelRuntimes.delete(sessionId);
1863
+ this.sessionModels.delete(sessionId);
1864
+ this.serviceScopes.delete(sessionId);
1865
+ this.revisions.delete(sessionId);
1866
+ this.thinkingSelections.delete(sessionId);
1867
+ this.reloadInFlight.delete(sessionId);
1053
1868
  }
1054
1869
  disposeAll() {
1055
1870
  for (const id of [...this.sessions.keys()])
@@ -1199,6 +2014,10 @@ export function createDefaultPiSdkBindings() {
1199
2014
  id: opts.sessionId,
1200
2015
  });
1201
2016
  },
2017
+ createInMemorySessionManager: async (opts) => {
2018
+ const sdk = (await loadSdk());
2019
+ return sdk.SessionManager.inMemory(opts.cwd);
2020
+ },
1202
2021
  forkSessionManager: async (opts) => {
1203
2022
  const sdk = (await loadSdk());
1204
2023
  return sdk.SessionManager.forkFrom(opts.sourcePath, opts.targetCwd, opts.targetSessionDir, { id: opts.targetSessionId });