@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.
- package/CHANGELOG.md +38 -17
- package/dist/application/task-lifecycle/advance.js +254 -4
- package/dist/application/task-lifecycle/gates.js +50 -0
- package/dist/application/task-lifecycle/observe.js +11 -2
- package/dist/commands/init-upgrade.js +32 -1
- package/dist/commands/init.js +94 -3
- package/dist/executors/shell-executor.js +4 -91
- package/dist/executors/shell-write-guard.js +26 -8
- package/dist/shared/operator/capabilities.js +72 -42
- package/dist/shared/resilient-git.js +133 -0
- package/dist/task/source-prepare/artifact-meta.js +137 -0
- package/dist/task/source-prepare/index.js +2 -0
- package/dist/task/source-prepare/parse-intent.js +58 -10
- package/dist/task/source-prepare/prepare.js +180 -16
- package/dist/task/source-prepare/reference-integrity.js +18 -2
- package/dist/task/source-prepare/semantic-intake.js +404 -0
- package/dist/worker/console/app-data.js +2 -0
- package/dist/worker/console/chat/chat-event-store.js +190 -25
- package/dist/worker/console/chat/model-resolver.js +17 -0
- package/dist/worker/console/chat/pi-console-config.js +250 -32
- package/dist/worker/console/chat/pi-runtime.js +1007 -188
- package/dist/worker/console/chat/resource-loader.js +5 -4
- package/dist/worker/console/chat/routes.js +495 -157
- package/dist/worker/console/chat/runtime-context.js +48 -12
- package/dist/worker/console/chat/runtime-selection.js +59 -0
- package/dist/worker/console/chat/session-store.js +39 -0
- package/dist/worker/console/chat/shortcuts.js +1 -0
- package/dist/worker/console/chat/tool-adapter.js +9 -3
- package/dist/worker/console/chat/tools.js +5 -1
- package/dist/worker/console/dag-execution-receipt.js +380 -0
- package/dist/worker/console/operator-actions.js +559 -68
- package/dist/worker/console/server.js +8 -15
- package/dist/worker/console/static/assets/index-BQkhJpV8.css +1 -0
- package/dist/worker/console/static/assets/index-CMHovlqG.js +32 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
- package/dist/worker/console/static-src/operator-chat/landing-density.js +23 -0
- package/dist/worker/console/static-src/operator-chat/refs.js +9 -0
- package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +257 -0
- package/dist/worker/console/static-src/operator-chat/session-title-watcher.js +128 -0
- package/dist/worker/console/static-src/operator-chat/sidebar-split.js +90 -0
- package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +37 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +125 -22
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +215 -184
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +49 -5
- package/dist/worker/console/static-src/operator-chat/useComposer.js +17 -0
- package/dist/worker/console/static-src/operator-chat/useOverlayFocus.js +84 -0
- package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +225 -74
- package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +196 -0
- package/dist/worker/console/static-src/operator-chat/useWorkspaceLayout.js +58 -0
- package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +31 -0
- package/dist/worker/delivery/final-verification.js +13 -5
- package/dist/worker/delivery/package.js +31 -19
- package/dist/worker/delivery/verification-bundle.js +6 -4
- package/dist/worker/observe/static/operator-chrome.css +5 -2
- package/dist/worker/observe/static/operator-chrome.js +6 -1
- package/dist/worker/observe/static/styles.css +39 -9
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +33 -462
- package/dist/workflows/dag/backend-test-case-manifest.js +0 -4
- package/dist/workflows/dag/backend-test-markdown-workflow.js +1 -25
- package/dist/workflows/dag/backend-test-module-stem.js +0 -5
- package/dist/workflows/dag/backend-test-pytest-collection.js +24 -345
- package/dist/workflows/dag/backend-test-scenario-param.js +82 -269
- package/dist/workflows/dag/backend-test-writer-completeness.js +16 -47
- package/dist/workflows/dag/dynamic-runtime/map.js +8 -24
- package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
- package/dist/workflows/dag/init-hybrid.js +34 -46
- package/dist/workflows/dag/types.js +0 -7
- package/dist/workflows/dag/workspace-checkpoint.js +8 -27
- package/docs/templates/backend-test-dag.json +29 -32
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/local-jacoco-coverage/SKILL.md +281 -0
- package/skills/local-jacoco-coverage/references/requirement-to-source-mapping.md +85 -0
- package/skills/local-jacoco-coverage/references/runtime-alignment.md +106 -0
- package/skills/local-jacoco-coverage/scripts/run-coverage-analysis.sh +148 -0
- package/skills/local-jacoco-coverage/scripts/start-jacoco-agent.sh +110 -0
- package/skills/loop-agent/references/command-reference.md +3 -1
- package/skills/loop-agent/references/source-and-plan-practice.md +13 -0
- package/skills/loop-agent/references/task-workflow.md +4 -0
- package/dist/worker/console/chat/instruction-skills.js +0 -217
- package/dist/worker/console/static/assets/index-CnUXAqxG.css +0 -1
- 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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
-
*
|
|
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
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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:
|
|
709
|
+
modelRuntime: runtime,
|
|
487
710
|
});
|
|
488
711
|
}
|
|
489
712
|
catch {
|
|
@@ -498,65 +721,120 @@ export class ConsolePiRuntime {
|
|
|
498
721
|
};
|
|
499
722
|
}
|
|
500
723
|
/**
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
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
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
|
862
|
+
...(customTools.length > 0 ? { customTools } : {}),
|
|
633
863
|
...(resolvedModel ? { model: resolvedModel } : {}),
|
|
634
864
|
});
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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(
|
|
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
|
|
751
|
-
const
|
|
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(
|
|
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
|
|
1608
|
+
model,
|
|
872
1609
|
activeTools: session.getActiveToolNames(),
|
|
873
1610
|
};
|
|
874
1611
|
}
|
|
875
1612
|
/**
|
|
876
|
-
* Shared services + pinned-tool materialization for
|
|
877
|
-
*
|
|
878
|
-
*
|
|
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
|
-
|
|
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
|
|
1653
|
+
...(customTools.length > 0 ? { customTools } : {}),
|
|
943
1654
|
...(resolvedModel ? { model: resolvedModel } : {}),
|
|
944
1655
|
});
|
|
945
|
-
return {
|
|
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
|
-
:
|
|
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 });
|