@tea-agent/loop-agent 0.33.5 → 0.33.6

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 (57) hide show
  1. package/CHANGELOG.md +33 -0
  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-write-guard.js +26 -8
  8. package/dist/shared/operator/capabilities.js +72 -42
  9. package/dist/shared/resilient-git.js +133 -0
  10. package/dist/task/source-prepare/artifact-meta.js +137 -0
  11. package/dist/task/source-prepare/index.js +2 -0
  12. package/dist/task/source-prepare/parse-intent.js +58 -10
  13. package/dist/task/source-prepare/prepare.js +180 -16
  14. package/dist/task/source-prepare/reference-integrity.js +18 -2
  15. package/dist/task/source-prepare/semantic-intake.js +404 -0
  16. package/dist/worker/console/app-data.js +2 -0
  17. package/dist/worker/console/chat/chat-event-store.js +190 -25
  18. package/dist/worker/console/chat/pi-console-config.js +250 -32
  19. package/dist/worker/console/chat/pi-runtime.js +625 -71
  20. package/dist/worker/console/chat/resource-loader.js +5 -4
  21. package/dist/worker/console/chat/routes.js +324 -146
  22. package/dist/worker/console/chat/runtime-context.js +48 -12
  23. package/dist/worker/console/chat/runtime-selection.js +59 -0
  24. package/dist/worker/console/chat/shortcuts.js +1 -0
  25. package/dist/worker/console/chat/tool-adapter.js +9 -3
  26. package/dist/worker/console/chat/tools.js +5 -1
  27. package/dist/worker/console/dag-execution-receipt.js +380 -0
  28. package/dist/worker/console/operator-actions.js +559 -68
  29. package/dist/worker/console/server.js +8 -15
  30. package/dist/worker/console/static/assets/index-BUOLppPr.js +28 -0
  31. package/dist/worker/console/static/assets/index-C1KzazY5.css +1 -0
  32. package/dist/worker/console/static/index.html +2 -2
  33. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
  34. package/dist/worker/console/static-src/operator-chat/refs.js +9 -0
  35. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +257 -0
  36. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +16 -0
  37. package/dist/worker/console/static-src/operator-chat/useChatStream.js +210 -184
  38. package/dist/worker/console/static-src/operator-chat/useChatThread.js +49 -5
  39. package/dist/worker/console/static-src/operator-chat/useComposer.js +17 -0
  40. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +225 -74
  41. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +196 -0
  42. package/dist/worker/delivery/final-verification.js +13 -5
  43. package/dist/worker/delivery/package.js +31 -19
  44. package/dist/worker/delivery/verification-bundle.js +6 -4
  45. package/dist/worker/observe/static/operator-chrome.css +5 -2
  46. package/dist/worker/observe/static/operator-chrome.js +6 -1
  47. package/dist/worker/observe/static/styles.css +39 -9
  48. package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
  49. package/dist/workflows/dag/workspace-checkpoint.js +8 -27
  50. package/harness.json +1 -1
  51. package/package.json +1 -1
  52. package/skills/loop-agent/references/command-reference.md +3 -1
  53. package/skills/loop-agent/references/source-and-plan-practice.md +13 -0
  54. package/skills/loop-agent/references/task-workflow.md +4 -0
  55. package/dist/worker/console/chat/instruction-skills.js +0 -217
  56. package/dist/worker/console/static/assets/index-CnUXAqxG.css +0 -1
  57. 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
36
  import { resolveDefaultChatModel, } 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.
@@ -433,9 +612,16 @@ export class ConsolePiRuntime {
433
612
  sessions = new Map();
434
613
  modelRuntimes = new Map();
435
614
  sessionManagers = new Map();
615
+ /** Per-session SDK service scope (resourceLoader / settingsManager) used by the live runtime snapshot. */
616
+ serviceScopes = new Map();
617
+ /** Snapshot revision: bumped on create / reopen / model / thinking / reload. */
618
+ revisions = new Map();
619
+ /** RF-01: per-session reload in-flight lock (concurrent second call → PI_SESSION_BUSY). */
620
+ reloadInFlight = new Set();
621
+ /** Composer thinking selection ("auto" = no explicit override). */
622
+ thinkingSelections = new Map();
436
623
  mainlineLeaves = new Map();
437
624
  bindings;
438
- disabledInstructionSkills = new Set();
439
625
  constructor(options) {
440
626
  this.options = options;
441
627
  this.loader = createOperatorChatResourceLoader();
@@ -461,9 +647,6 @@ export class ConsolePiRuntime {
461
647
  })),
462
648
  };
463
649
  }
464
- setDisabledInstructionSkills(names) {
465
- this.disabledInstructionSkills = new Set(names);
466
- }
467
650
  /** Operator action context used by custom-tool dispatchers (Gate 3). */
468
651
  get actionContext() {
469
652
  return this.options.actionContext;
@@ -497,22 +680,6 @@ export class ConsolePiRuntime {
497
680
  modelId: descriptor.modelId,
498
681
  };
499
682
  }
500
- /**
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.
505
- */
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);
510
- }
511
- catch (error) {
512
- process.stderr.write(`[console] chat instruction-skills load failed: ${error instanceof Error ? error.message : String(error)}\n`);
513
- return "";
514
- }
515
- }
516
683
  /** Create a new isolated Chat session pinned to the operator-chat tool set. */
517
684
  async createSession(init) {
518
685
  const sessionId = `operator-chat-${randomBytes(12).toString("hex")}`;
@@ -528,28 +695,18 @@ export class ConsolePiRuntime {
528
695
  }
529
696
  // Final fallback only when nothing resolved: let the SDK pick its default
530
697
  // (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();
698
+ // Standard open resource surface (ADR 0012): the SDK DefaultResourceLoader
699
+ // loads user/project AGENTS.md context files, skills (~/.pi/agent/skills/
700
+ // + .agents/skills/ init projection) and user/project extensions. Only
701
+ // appendSystemPrompt carries the operator-chat system prompt.
535
702
  const appendSystemPrompt = [
536
703
  OPERATOR_CHAT_SYSTEM_PROMPT_BASE,
537
- ...(skillFragment ? [skillFragment] : []),
538
704
  ...(init?.systemPromptSuffix ? [init.systemPromptSuffix] : []),
539
705
  ];
540
706
  const { services } = await this.bindings.createServices({
541
707
  cwd: this.options.cwd,
542
708
  agentDir,
543
709
  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
710
  appendSystemPrompt,
554
711
  },
555
712
  });
@@ -633,12 +790,19 @@ export class ConsolePiRuntime {
633
790
  ...(resolvedModel ? { model: resolvedModel } : {}),
634
791
  });
635
792
  // 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());
793
+ // defaults). Active set = operator baseline + built-in explore tools +
794
+ // extension tools registered by user/project extensions (ADR 0012).
795
+ // Non-Pi write channels are excluded above and cannot appear here.
796
+ session.setActiveToolsByName(computeOperatorChatActiveToolNames(session));
639
797
  this.sessions.set(sessionId, session);
640
798
  this.sessionManagers.set(sessionId, sessionManager);
641
799
  this.modelRuntimes.set(sessionId, modelRuntime);
800
+ this.serviceScopes.set(sessionId, {
801
+ resourceLoader: services.resourceLoader,
802
+ settingsManager: services.settingsManager,
803
+ });
804
+ this.revisions.set(sessionId, 1);
805
+ this.thinkingSelections.set(sessionId, "auto");
642
806
  return {
643
807
  sessionId,
644
808
  sessionFile: session.sessionFile,
@@ -661,10 +825,13 @@ export class ConsolePiRuntime {
661
825
  sessionManager,
662
826
  model: init.model,
663
827
  });
664
- built.session.setActiveToolsByName(OPERATOR_CHAT_ACTIVE_TOOL_NAMES());
828
+ built.session.setActiveToolsByName(computeOperatorChatActiveToolNames(built.session));
665
829
  this.sessions.set(sessionId, built.session);
666
830
  this.sessionManagers.set(sessionId, sessionManager);
667
831
  this.modelRuntimes.set(sessionId, built.modelRuntime);
832
+ this.serviceScopes.set(sessionId, built.services);
833
+ this.revisions.set(sessionId, 1);
834
+ this.thinkingSelections.set(sessionId, "auto");
668
835
  return {
669
836
  sessionId,
670
837
  sessionFile: built.session.sessionFile,
@@ -729,18 +896,31 @@ export class ConsolePiRuntime {
729
896
  if (!resolved)
730
897
  throw new Error("MODEL_NOT_AVAILABLE");
731
898
  await session.setModel(resolved);
899
+ this.bumpRevision(sessionId);
732
900
  return {
733
901
  provider: session.model?.provider ?? model.provider,
734
902
  modelId: session.model?.id ?? session.model?.modelId ?? model.modelId,
735
903
  };
736
904
  }
905
+ /**
906
+ * Apply a thinking-level selection. `auto` records the selection WITHOUT
907
+ * sending any explicit override (the SDK keeps its own default); an explicit
908
+ * level calls `session.setThinkingLevel`. Returns the effective value.
909
+ */
737
910
  applyThinkingLevel(sessionId, level) {
738
911
  const session = this.sessions.get(sessionId);
739
912
  if (!session)
740
913
  throw new Error(`chat session not active: ${sessionId}`);
914
+ if (level === "auto") {
915
+ this.thinkingSelections.set(sessionId, "auto");
916
+ this.bumpRevision(sessionId);
917
+ return "auto";
918
+ }
741
919
  if (!THINKING_LEVELS.includes(level) || !session.setThinkingLevel)
742
920
  throw new Error("THINKING_LEVEL_UNSUPPORTED");
743
921
  session.setThinkingLevel(level);
922
+ this.thinkingSelections.set(sessionId, level);
923
+ this.bumpRevision(sessionId);
744
924
  return session.thinkingLevel ?? level;
745
925
  }
746
926
  getRuntimeSelection(sessionId) {
@@ -757,6 +937,370 @@ export class ConsolePiRuntime {
757
937
  activeTools: session.getActiveToolNames(),
758
938
  };
759
939
  }
940
+ bumpRevision(sessionId) {
941
+ this.revisions.set(sessionId, (this.revisions.get(sessionId) ?? 0) + 1);
942
+ }
943
+ /**
944
+ * Resolve the RAW system prompt actually injected into the Pi session
945
+ * (design §2.2 / AC-03). Fallback chain: agent.state.systemPrompt →
946
+ * session.systemPrompt getter → ResourceLoader.getSystemPrompt() → the
947
+ * composed operator-chat base prompt. Never returns empty.
948
+ */
949
+ async getRuntimeSystemPrompt(sessionId) {
950
+ const session = this.sessions.get(sessionId);
951
+ if (!session)
952
+ return composeOperatorChatSystemPrompt({});
953
+ const fromState = session.state?.systemPrompt;
954
+ const fromGetter = session.systemPrompt;
955
+ const loader = this.serviceScopes.get(sessionId)?.resourceLoader;
956
+ return (fromState ||
957
+ fromGetter ||
958
+ loader?.getSystemPrompt?.() ||
959
+ composeOperatorChatSystemPrompt({}));
960
+ }
961
+ /**
962
+ * Read the RAW system prompt for the snapshot, converting any projection
963
+ * failure into the structured critical error (RF-02): a broken system prompt
964
+ * source must fail the whole snapshot with 503 PI_RUNTIME_UNAVAILABLE rather
965
+ * than silently fabricate prompt text.
966
+ */
967
+ async readRawSystemPrompt(sessionId) {
968
+ try {
969
+ return await this.getRuntimeSystemPrompt(sessionId);
970
+ }
971
+ catch (error) {
972
+ throw new RuntimeSnapshotUnavailableError(`system prompt projection failed: ${error instanceof Error ? error.message : String(error)}`);
973
+ }
974
+ }
975
+ /**
976
+ * Official Pi session reload (AC-05 / AC-06). Rejects with a structured
977
+ * code while the session is busy (streaming / compacting / bash in flight /
978
+ * duplicate reload) — never aborts, never silently queues.
979
+ */
980
+ async reloadSession(sessionId) {
981
+ const session = this.sessions.get(sessionId);
982
+ if (!session) {
983
+ return {
984
+ ok: false,
985
+ code: "PI_SESSION_NOT_FOUND",
986
+ message: `chat session not active: ${sessionId}`,
987
+ };
988
+ }
989
+ // RF2-03: the per-sessionId in-flight lock is checked FIRST — before the
990
+ // streaming/compacting/bash/support checks. JS runs check-then-add in one
991
+ // synchronous segment, so two concurrent calls cannot both pass; the lock
992
+ // is released in `finally` on BOTH the success and the error path, and
993
+ // different sessions never block each other. Priority matters: the first
994
+ // reload() may synchronously flip isIdle/isCompacting/isBashRunning/
995
+ // hasPendingBashMessages to busy, and a second concurrent call must still
996
+ // report the reload-in-flight busy (not the streaming/compacting one).
997
+ if (this.reloadInFlight.has(sessionId)) {
998
+ return {
999
+ ok: false,
1000
+ code: "PI_SESSION_BUSY",
1001
+ message: "session is busy (reload in flight); reload rejected without abort",
1002
+ };
1003
+ }
1004
+ const busyReasons = [];
1005
+ if (session.isStreaming === true || session.isIdle === false)
1006
+ busyReasons.push("streaming");
1007
+ if (session.isCompacting === true)
1008
+ busyReasons.push("compacting");
1009
+ if (session.isBashRunning === true)
1010
+ busyReasons.push("bash running");
1011
+ if (session.hasPendingBashMessages === true)
1012
+ busyReasons.push("bash messages pending");
1013
+ if (busyReasons.length > 0) {
1014
+ return {
1015
+ ok: false,
1016
+ code: "PI_SESSION_BUSY",
1017
+ message: `session is busy (${busyReasons.join(", ")}); reload rejected without abort`,
1018
+ };
1019
+ }
1020
+ if (typeof session.reload !== "function") {
1021
+ return {
1022
+ ok: false,
1023
+ code: "PI_RELOAD_FAILED",
1024
+ message: "this Pi runtime does not support session.reload()",
1025
+ };
1026
+ }
1027
+ this.reloadInFlight.add(sessionId);
1028
+ try {
1029
+ await session.reload();
1030
+ }
1031
+ catch (error) {
1032
+ return {
1033
+ ok: false,
1034
+ code: "PI_RELOAD_FAILED",
1035
+ message: error instanceof Error ? error.message : String(error),
1036
+ };
1037
+ }
1038
+ finally {
1039
+ this.reloadInFlight.delete(sessionId);
1040
+ }
1041
+ this.bumpRevision(sessionId);
1042
+ return { ok: true, revision: this.revisions.get(sessionId) ?? 0 };
1043
+ }
1044
+ /**
1045
+ * Project the unified live runtime snapshot (design §2.2). The current Pi
1046
+ * session is the single source of truth: real system prompt, real model /
1047
+ * thinking selection (auto + effective), the session modelRuntime model
1048
+ * list, and the session ResourceLoader inventories. Undefined when the
1049
+ * session is not active — callers fail closed instead of fabricating data.
1050
+ */
1051
+ async getRuntimeSnapshot(sessionId, options) {
1052
+ const session = this.sessions.get(sessionId);
1053
+ if (!session)
1054
+ return undefined;
1055
+ const services = this.serviceScopes.get(sessionId);
1056
+ const modelRuntime = this.modelRuntimes.get(sessionId);
1057
+ const resourceLoader = services?.resourceLoader;
1058
+ const settingsManager = services?.settingsManager;
1059
+ const thinkingSelection = this.thinkingSelections.get(sessionId) ?? "auto";
1060
+ const effectiveThinkingLevel = session.thinkingLevel ??
1061
+ settingsManager?.getDefaultThinkingLevel?.() ??
1062
+ (thinkingSelection === "auto" ? undefined : thinkingSelection);
1063
+ const rawSystemPrompt = await this.readRawSystemPrompt(sessionId);
1064
+ // Models come from the session modelRuntime. A model-enumeration failure
1065
+ // is a CRITICAL projection failure (RF-02): it throws
1066
+ // RuntimeSnapshotUnavailableError so consuming routes return 503
1067
+ // PI_RUNTIME_UNAVAILABLE — never a fabricated empty model list.
1068
+ let models = [];
1069
+ if (modelRuntime) {
1070
+ try {
1071
+ models = this.bindings
1072
+ .listAvailableModels({ modelRuntime })
1073
+ .map((entry) => ({
1074
+ provider: entry.provider,
1075
+ id: entry.id,
1076
+ ...(entry.name ? { name: entry.name } : {}),
1077
+ hasCredentials: entry.hasCredentials,
1078
+ }));
1079
+ }
1080
+ catch (error) {
1081
+ throw new RuntimeSnapshotUnavailableError(`model enumeration failed: ${error instanceof Error ? error.message : String(error)}`);
1082
+ }
1083
+ }
1084
+ // Thinking options: auto is always offered; explicit levels only when the
1085
+ // runtime session supports setThinkingLevel (xhigh/max ship with the SDK
1086
+ // and therefore surface only when this runtime provides them).
1087
+ const supportedLevels = typeof session.setThinkingLevel === "function"
1088
+ ? [...THINKING_LEVELS]
1089
+ : [];
1090
+ const thinkingLevels = [
1091
+ { value: "auto" },
1092
+ ...supportedLevels.map((value) => ({ value })),
1093
+ ];
1094
+ // Resource inventories from the session ResourceLoader (ADR 0012 open
1095
+ // surface). Missing loader → real empty lists, never fabricated entries.
1096
+ // Each subface is isolated (RF-02): a throwing subface keeps the other
1097
+ // inventories AND surfaces a structured retryable diagnostic instead of
1098
+ // silently dropping the whole resource projection.
1099
+ const contextFiles = [];
1100
+ const skills = [];
1101
+ const extensions = [];
1102
+ const diagnostics = [];
1103
+ let resolvedResources = [];
1104
+ const resourceFailure = (subface, error) => {
1105
+ diagnostics.push({
1106
+ type: "error",
1107
+ code: "RESOURCE_LOAD_FAILED",
1108
+ message: `${subface} inventory failed: ${error instanceof Error ? error.message : String(error)}`,
1109
+ retryable: true,
1110
+ });
1111
+ };
1112
+ if (resourceLoader) {
1113
+ try {
1114
+ const agentsFiles = resourceLoader.getAgentsFiles?.();
1115
+ for (const file of agentsFiles?.agentsFiles ?? []) {
1116
+ if (!file.path)
1117
+ continue;
1118
+ contextFiles.push({
1119
+ path: file.path,
1120
+ characters: file.content?.length ?? 0,
1121
+ });
1122
+ }
1123
+ }
1124
+ catch (error) {
1125
+ resourceFailure("context files", error);
1126
+ }
1127
+ try {
1128
+ const skillsResult = resourceLoader.getSkills?.();
1129
+ for (const skill of skillsResult?.skills ?? []) {
1130
+ skills.push({
1131
+ name: skill.name,
1132
+ description: skill.description ?? "",
1133
+ path: skill.filePath,
1134
+ ...(skill.baseDir ? { baseDir: skill.baseDir } : {}),
1135
+ scope: skill.sourceInfo?.scope ?? "unknown",
1136
+ source: skill.sourceInfo?.source ?? "",
1137
+ enabled: !skill.disableModelInvocation,
1138
+ diagnostics: [],
1139
+ });
1140
+ resolvedResources.push({
1141
+ type: "skill",
1142
+ name: skill.name,
1143
+ path: skill.filePath,
1144
+ source: skill.sourceInfo?.source ?? "",
1145
+ scope: skill.sourceInfo?.scope ?? "unknown",
1146
+ });
1147
+ }
1148
+ for (const diag of skillsResult?.diagnostics ?? []) {
1149
+ if (!diag?.message)
1150
+ continue;
1151
+ diagnostics.push({
1152
+ type: diag.type ?? "warning",
1153
+ message: diag.message,
1154
+ ...(diag.path ? { path: diag.path } : {}),
1155
+ });
1156
+ }
1157
+ }
1158
+ catch (error) {
1159
+ resourceFailure("skills", error);
1160
+ }
1161
+ try {
1162
+ const extResult = resourceLoader.getExtensions?.();
1163
+ for (const ext of extResult?.extensions ?? []) {
1164
+ const path = ext.resolvedPath ?? ext.path ?? "";
1165
+ extensions.push({
1166
+ name: ext.sourceInfo?.source ?? ext.path ?? "extension",
1167
+ path,
1168
+ ...(ext.resolvedPath ? { resolvedPath: ext.resolvedPath } : {}),
1169
+ source: ext.sourceInfo?.source ?? "",
1170
+ scope: ext.sourceInfo?.scope ?? "unknown",
1171
+ ...(ext.sourceInfo?.origin ? { origin: ext.sourceInfo.origin } : {}),
1172
+ enabled: true,
1173
+ });
1174
+ resolvedResources.push({
1175
+ type: "extension",
1176
+ name: ext.sourceInfo?.source ?? ext.path ?? "extension",
1177
+ path,
1178
+ source: ext.sourceInfo?.source ?? "",
1179
+ scope: ext.sourceInfo?.scope ?? "unknown",
1180
+ });
1181
+ }
1182
+ for (const err of extResult?.errors ?? []) {
1183
+ if (!err?.error)
1184
+ continue;
1185
+ diagnostics.push({
1186
+ type: "error",
1187
+ message: err.error,
1188
+ ...(err.path ? { path: err.path } : {}),
1189
+ });
1190
+ }
1191
+ }
1192
+ catch (error) {
1193
+ resourceFailure("extensions", error);
1194
+ }
1195
+ try {
1196
+ const promptsResult = resourceLoader.getPrompts?.();
1197
+ for (const prompt of promptsResult?.prompts ?? []) {
1198
+ resolvedResources.push({
1199
+ type: "prompt",
1200
+ name: prompt.name,
1201
+ path: prompt.filePath,
1202
+ source: prompt.sourceInfo?.source ?? "",
1203
+ scope: prompt.sourceInfo?.scope ?? "unknown",
1204
+ });
1205
+ }
1206
+ for (const diag of promptsResult?.diagnostics ?? []) {
1207
+ if (!diag?.message)
1208
+ continue;
1209
+ diagnostics.push({
1210
+ type: diag.type ?? "warning",
1211
+ message: diag.message,
1212
+ ...(diag.path ? { path: diag.path } : {}),
1213
+ });
1214
+ }
1215
+ }
1216
+ catch (error) {
1217
+ resourceFailure("prompts", error);
1218
+ }
1219
+ try {
1220
+ const themesResult = resourceLoader.getThemes?.();
1221
+ for (const theme of themesResult?.themes ?? []) {
1222
+ if (!theme.path)
1223
+ continue;
1224
+ resolvedResources.push({
1225
+ type: "theme",
1226
+ name: theme.name ?? theme.path,
1227
+ path: theme.path,
1228
+ source: "",
1229
+ scope: "unknown",
1230
+ });
1231
+ }
1232
+ for (const diag of themesResult?.diagnostics ?? []) {
1233
+ if (!diag?.message)
1234
+ continue;
1235
+ diagnostics.push({
1236
+ type: diag.type ?? "warning",
1237
+ message: diag.message,
1238
+ ...(diag.path ? { path: diag.path } : {}),
1239
+ });
1240
+ }
1241
+ }
1242
+ catch (error) {
1243
+ resourceFailure("themes", error);
1244
+ }
1245
+ }
1246
+ // Packages: Pi settings specs + resolved resource metadata. Fields the SDK
1247
+ // does not expose stay absent (real missing, never fabricated). A
1248
+ // readPackageInventory failure is a PARTIAL failure (RF-02): the snapshot
1249
+ // stays 200 with a structured retryable diagnostic — never a silent
1250
+ // fake-empty package list.
1251
+ const { readPackageInventory } = await import("./pi-console-config.js");
1252
+ let packages = [];
1253
+ try {
1254
+ packages = (await readPackageInventory(await safeGetAgentDir(this.bindings), this.options.cwd, resolvedResources));
1255
+ }
1256
+ catch (error) {
1257
+ packages = [];
1258
+ diagnostics.push({
1259
+ type: "error",
1260
+ code: "PACKAGE_INVENTORY_FAILED",
1261
+ message: error instanceof Error ? error.message : String(error),
1262
+ retryable: true,
1263
+ });
1264
+ }
1265
+ return projectRuntimeSnapshot({
1266
+ sessionId,
1267
+ revision: this.revisions.get(sessionId) ?? 0,
1268
+ loadedAt: new Date().toISOString(),
1269
+ selection: {
1270
+ ...(session.model?.provider &&
1271
+ (session.model?.id ?? session.model?.modelId)
1272
+ ? {
1273
+ model: {
1274
+ provider: session.model.provider,
1275
+ id: session.model.id ?? session.model.modelId,
1276
+ ...(session.model.name ? { name: session.model.name } : {}),
1277
+ },
1278
+ }
1279
+ : {}),
1280
+ thinkingSelection,
1281
+ ...(effectiveThinkingLevel
1282
+ ? { effectiveThinkingLevel: effectiveThinkingLevel }
1283
+ : {}),
1284
+ },
1285
+ models,
1286
+ thinkingLevels,
1287
+ systemPrompt: {
1288
+ ...(options?.includeRaw ? { raw: rawSystemPrompt } : {}),
1289
+ // The RAW prompt flows into the projection: text is scrubbed +
1290
+ // length-limited while sha256/characters stay RAW-based (design §2.2).
1291
+ text: rawSystemPrompt,
1292
+ sha256: createHash("sha256").update(rawSystemPrompt).digest("hex"),
1293
+ characters: rawSystemPrompt.length,
1294
+ },
1295
+ activeTools: session.getActiveToolNames(),
1296
+ contextFiles,
1297
+ skills,
1298
+ extensions,
1299
+ packages,
1300
+ diagnostics,
1301
+ hasBash: true,
1302
+ });
1303
+ }
760
1304
  /**
761
1305
  * Re-open a previously persisted Chat session after Console restart or
762
1306
  * browser refresh (roadmap M1-S04 / S05).
@@ -838,10 +1382,13 @@ export class ConsolePiRuntime {
838
1382
  message: `reopened Pi sessionId mismatch: expected ${sessionId}, got ${reportedId}`,
839
1383
  };
840
1384
  }
841
- session.setActiveToolsByName(OPERATOR_CHAT_ACTIVE_TOOL_NAMES());
1385
+ session.setActiveToolsByName(computeOperatorChatActiveToolNames(session));
842
1386
  this.sessions.set(sessionId, session);
843
1387
  this.sessionManagers.set(sessionId, sessionManager);
844
1388
  this.modelRuntimes.set(sessionId, built.modelRuntime);
1389
+ this.serviceScopes.set(sessionId, built.services);
1390
+ this.revisions.set(sessionId, 1);
1391
+ this.thinkingSelections.set(sessionId, "auto");
845
1392
  return {
846
1393
  ok: true,
847
1394
  handle: {
@@ -880,19 +1427,14 @@ export class ConsolePiRuntime {
880
1427
  */
881
1428
  async buildSessionWithServices(init) {
882
1429
  const agentDir = await safeGetAgentDir(this.bindings);
883
- const skillFragment = await this.composeSystemPromptSkills();
884
1430
  const appendSystemPrompt = [
885
1431
  OPERATOR_CHAT_SYSTEM_PROMPT_BASE,
886
- ...(skillFragment ? [skillFragment] : []),
887
1432
  ...(init.systemPromptSuffix ? [init.systemPromptSuffix] : []),
888
1433
  ];
889
1434
  const { services } = await this.bindings.createServices({
890
1435
  cwd: this.options.cwd,
891
1436
  agentDir,
892
1437
  resourceLoaderOptions: {
893
- noContextFiles: true,
894
- noSkills: true,
895
- noExtensions: true,
896
1438
  appendSystemPrompt,
897
1439
  },
898
1440
  });
@@ -942,7 +1484,14 @@ export class ConsolePiRuntime {
942
1484
  ...(customTools && customTools.length > 0 ? { customTools } : {}),
943
1485
  ...(resolvedModel ? { model: resolvedModel } : {}),
944
1486
  });
945
- return { session, modelRuntime };
1487
+ return {
1488
+ session,
1489
+ modelRuntime,
1490
+ services: {
1491
+ resourceLoader: services.resourceLoader,
1492
+ settingsManager: services.settingsManager,
1493
+ },
1494
+ };
946
1495
  }
947
1496
  /** Re-open a previously created session (cross-request). */
948
1497
  open(sessionId) {
@@ -963,11 +1512,12 @@ export class ConsolePiRuntime {
963
1512
  }
964
1513
  // Gate 2 (re-pin before each turn): interview turns pass through the
965
1514
  // existing closed interview allow/deny registry. The next ordinary turn
966
- // explicitly restores the normal Operator Chat set.
1515
+ // explicitly restores the normal Operator Chat set (baseline + extension
1516
+ // tools, ADR 0012).
967
1517
  const requestedTools = OPERATOR_CHAT_ACTIVE_TOOL_NAMES();
968
1518
  const activeTools = options?.mode === "requirement-interview"
969
1519
  ? filterActiveInterviewTools(requestedTools).allowed
970
- : requestedTools;
1520
+ : computeOperatorChatActiveToolNames(session);
971
1521
  session.setActiveToolsByName(activeTools);
972
1522
  const unsub = session.subscribe((event) => {
973
1523
  const mapped = mapSdkEvent(sessionId, event);
@@ -1049,6 +1599,10 @@ export class ConsolePiRuntime {
1049
1599
  this.sessionManagers.delete(sessionId);
1050
1600
  this.mainlineLeaves.delete(sessionId);
1051
1601
  this.modelRuntimes.delete(sessionId);
1602
+ this.serviceScopes.delete(sessionId);
1603
+ this.revisions.delete(sessionId);
1604
+ this.thinkingSelections.delete(sessionId);
1605
+ this.reloadInFlight.delete(sessionId);
1052
1606
  }
1053
1607
  }
1054
1608
  disposeAll() {