@tea-agent/loop-agent 0.22.0 → 0.24.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/AGENTS.md +42 -108
- package/CHANGELOG.md +85 -0
- package/README.md +8 -5
- package/bin/agent-worker.js +0 -0
- package/dist/application/context-usage/skill-resolution-stats.js +263 -0
- package/dist/application/dag/generate-task-dag.js +17 -3
- package/dist/cli/command-definitions.js +8 -7
- package/dist/cli/program.js +17 -15
- package/dist/commands/doctor.js +269 -18
- package/dist/commands/init.js +101 -86
- package/dist/commands/stats.js +40 -11
- package/dist/executors/shell-executor.js +20 -7
- package/dist/shared/operator/capabilities.js +486 -3
- package/dist/worker/console/app-data.js +6 -0
- package/dist/worker/console/chat/artifact-card.js +23 -0
- package/dist/worker/console/chat/chat-event-store.js +495 -0
- package/dist/worker/console/chat/chat-ui-policy.js +25 -0
- package/dist/worker/console/chat/composer-draft-store.js +45 -0
- package/dist/worker/console/chat/context-panel.js +54 -0
- package/dist/worker/console/chat/contract-apply-receipt-store.js +174 -0
- package/dist/worker/console/chat/explore-tools.js +299 -0
- package/dist/worker/console/chat/human-gate-card.js +37 -0
- package/dist/worker/console/chat/instruction-skills.js +217 -0
- package/dist/worker/console/chat/interview-adapter.js +136 -0
- package/dist/worker/console/chat/model-resolver.js +106 -0
- package/dist/worker/console/chat/operation-card.js +23 -0
- package/dist/worker/console/chat/pi-console-config.js +158 -0
- package/dist/worker/console/chat/pi-runtime.js +1143 -0
- package/dist/worker/console/chat/repo-browser.js +140 -0
- package/dist/worker/console/chat/repo-walk.js +116 -0
- package/dist/worker/console/chat/resource-loader.js +67 -0
- package/dist/worker/console/chat/routes.js +1646 -0
- package/dist/worker/console/chat/runtime-context.js +24 -0
- package/dist/worker/console/chat/runtime-selection.js +37 -0
- package/dist/worker/console/chat/session-store.js +437 -0
- package/dist/worker/console/chat/shortcuts.js +15 -0
- package/dist/worker/console/chat/tool-adapter.js +125 -0
- package/dist/worker/console/chat/tools.js +195 -0
- package/dist/worker/console/chat/usage.js +37 -0
- package/dist/worker/console/chat/workspace-landing.js +56 -0
- package/dist/worker/console/dag-confirmation.js +42 -8
- package/dist/worker/console/human-gate-token.js +130 -0
- package/dist/worker/console/mutation-gate-receipt-store.js +184 -0
- package/dist/worker/console/operation-runner.js +6 -2
- package/dist/worker/console/operation-sse.js +26 -0
- package/dist/worker/console/operator-actions.js +420 -7
- package/dist/worker/console/server.js +68 -1
- package/dist/worker/console/static/assets/index-BTbrEHnO.css +1 -0
- package/dist/worker/console/static/assets/index-D9qLevoP.js +27 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/feature/profile-schema.js +1 -1
- package/dist/workflows/dag/backend-test-markdown-workflow.js +9 -5
- package/dist/workflows/dag/backend-test-result-contract.js +229 -0
- package/dist/workflows/dag/frontend-lint-baseline.js +4 -4
- package/dist/workflows/dag/init-hybrid.js +71 -22
- package/dist/workflows/dag/node-execution.js +38 -1
- package/dist/workflows/dag/output-protocol.js +89 -0
- package/dist/workflows/dag/prompt.js +35 -1
- package/dist/workflows/dag/recovery-recommendation.js +45 -0
- package/dist/workflows/dag/report.js +28 -1
- package/dist/workflows/dag/rerun-task.js +1 -1
- package/dist/workflows/dag/scheduler.js +9 -0
- package/dist/workflows/dag/types.js +12 -0
- package/dist/workflows/dag/validate.js +55 -0
- package/docs/README.md +73 -156
- package/docs/architecture/README.md +7 -6
- package/docs/architecture/dag-execution.md +2 -2
- package/docs/architecture/evolution.md +16 -14
- package/docs/architecture/system-overview.md +1 -1
- package/docs/architecture/worker-and-feature.md +3 -3
- package/docs/governance/README.md +15 -0
- package/docs/{harness-methodology-debugging.md → governance/harness-methodology-debugging.md} +27 -3
- package/docs/init-surface.manifest.json +22 -4
- package/docs/operations/README.md +12 -0
- package/docs/{local-development-environment.md → operations/local-development-environment.md} +1 -1
- package/docs/skills/vetted-skill-registry.md +23 -3
- package/docs/templates/README.md +55 -0
- package/docs/templates/backend-test-dag.json +2 -2
- package/docs/templates/evaluation/agents-map-slim-v1.candidate.json +9 -0
- package/docs/templates/evaluation/agents-map-slim-v1.md +87 -0
- package/docs/templates/evaluation/agents-map-verbose-v0.candidate.json +9 -0
- package/docs/templates/evaluation/agents-map-verbose-v0.md +153 -0
- package/docs/templates/hybrid-dag.json +1 -1
- package/docs/templates/progress-log.md +9 -2
- package/harness.json +4 -4
- package/package.json +5 -5
- package/scripts/kb-bootstrap-init-skeleton.sh +2 -2
- package/skills/agent-worker/SKILL.md +1 -1
- package/skills/grill-with-docs/SKILL.md +44 -52
- package/skills/grill-with-docs/adr-format.md +37 -26
- package/skills/grill-with-docs/context-format.md +18 -26
- package/skills/loop-agent/SKILL.md +28 -112
- package/skills/loop-agent/references/command-reference.md +9 -3
- package/skills/loop-agent/references/harness-policy.md +3 -3
- package/skills/loop-agent/references/hybrid-dag.md +1 -1
- package/skills/loop-agent/references/orchestrator-and-interventions.md +1 -1
- package/skills/loop-agent/references/task-workflow.md +2 -0
- package/skills/systematic-debugging/SKILL.md +20 -4
- package/skills/test-driven-development/SKILL.md +10 -3
- package/dist/worker/console/static/assets/index-CUDke82y.js +0 -18
- package/dist/worker/console/static/assets/index-wSEksVSO.css +0 -1
- /package/docs/{harness-methodology-tdd.md → governance/harness-methodology-tdd.md} +0 -0
- /package/docs/{harness-methodology-verification.md → governance/harness-methodology-verification.md} +0 -0
|
@@ -0,0 +1,1143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator Chat — Pi SDK runtime wrapper (plan W1 / M1).
|
|
3
|
+
*
|
|
4
|
+
* Wraps the Pi SDK's services-mode session API
|
|
5
|
+
* (createAgentSessionServices + createAgentSessionFromServices + SessionManager)
|
|
6
|
+
* into a small, testable surface that the Console HTTP layer can drive.
|
|
7
|
+
*
|
|
8
|
+
* Isolation guarantees (plan D4 / V12 / V14):
|
|
9
|
+
* - Chat sessions are persisted to a DEDICATED sessionDir (not the user's
|
|
10
|
+
* default ~/.pi/agent/sessions). Naming prefix `operator-chat-` makes them
|
|
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.
|
|
15
|
+
* - Active tools are pinned to the operator-chat surface at session create
|
|
16
|
+
* AND re-pinned before each prompt (three-gate, design §7.5): the FULL
|
|
17
|
+
* operator action set (dynamic, from the registry) PLUS the built-in
|
|
18
|
+
* read/explore tools (bash/read/grep/find/ls). File-WRITING coding tools
|
|
19
|
+
* (edit/write/apply_patch/full-tools/shell/coding-chat) are excluded from
|
|
20
|
+
* the SDK registry and can never be activated (2026-07-25 widening).
|
|
21
|
+
*
|
|
22
|
+
* The actual SDK calls are injected via `PiSdkBindings` so this module is
|
|
23
|
+
* unit-testable without a live Pi install. Production bindings come from
|
|
24
|
+
* `createDefaultPiSdkBindings()`.
|
|
25
|
+
*/
|
|
26
|
+
import { randomBytes } from "node:crypto";
|
|
27
|
+
import { projectCompactSnapshot } from "./chat-event-store.js";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { extractUsageSample } from "./usage.js";
|
|
30
|
+
import { OPERATOR_CHAT_ALLOWED_TOOLS, authorizeOperatorChatTool, assertNoWriteToolInList, } from "./tools.js";
|
|
31
|
+
import { createOperatorChatResourceLoader, } from "./resource-loader.js";
|
|
32
|
+
import { loadOperatorChatInstructionSkills, composeInstructionSkillsPrompt, OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS, } from "./instruction-skills.js";
|
|
33
|
+
import { buildModelCallableToolSchemas } from "./tool-adapter.js";
|
|
34
|
+
import { resolveDefaultChatModel, } from "./model-resolver.js";
|
|
35
|
+
import { filterActiveInterviewTools } from "../interview/tools.js";
|
|
36
|
+
/**
|
|
37
|
+
* Base system prompt fragment every General Operator Chat session receives.
|
|
38
|
+
*
|
|
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.
|
|
47
|
+
*
|
|
48
|
+
* Instruction skills are appended AFTER this as read-only context (plan D1).
|
|
49
|
+
*/
|
|
50
|
+
const OPERATOR_CHAT_SYSTEM_PROMPT_BASE = [
|
|
51
|
+
"You are the General Operator Chat for loop-agent / agent-worker.",
|
|
52
|
+
"You are an OPERATOR: you orchestrate and inspect via the registered operator_* tools. You are NOT an implementer.",
|
|
53
|
+
"Tool surface: you have safe read-only explore tools (safe-read, safe-grep, git-status, git-diff, find, ls) to probe the repo, AND the model-callable operator_* tools. bash is NOT available (it was removed to close a write-via-redirect escape); use safe-read / safe-grep / git-status / git-diff instead.",
|
|
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. Do not attempt to read those files via any other path.",
|
|
55
|
+
"You do NOT have edit / write / apply_patch / full-tools / shell / coding-chat — file-WRITING is gated behind the DAG path. All canonical writes still go through DAG implement-pi / repair-pi, never via this chat.",
|
|
56
|
+
"High-risk mutations are not model-executable tools. 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, use read-only tools (status, doctor, dagReport, inspect, contractShow, safe-read, safe-grep) to diagnose.",
|
|
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 tools (status, doctor, dagReport, inspect, contractShow, safe-read, safe-grep, git-status) to diagnose before acting.",
|
|
59
|
+
].join("\n");
|
|
60
|
+
/**
|
|
61
|
+
* Built-in SDK read/explore tool names registered for every Chat session.
|
|
62
|
+
*
|
|
63
|
+
* M0-A (roadmap §14 工作块 A): bash is REMOVED — it could write files via
|
|
64
|
+
* `echo >` / `tee` / `sed -i` / `node -e writeFile`, bypassing the DAG write
|
|
65
|
+
* boundary. The residual read-only repo probing is served by the custom
|
|
66
|
+
* safe-read / safe-grep / find / ls tools (see buildExploreCustomTools) which
|
|
67
|
+
* enforce a repo-relative path boundary, a sensitive-file denylist, and
|
|
68
|
+
* secret scrubbing on the returned content. edit/write/... stay excluded.
|
|
69
|
+
*/
|
|
70
|
+
export const OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS = Object.freeze([
|
|
71
|
+
"find",
|
|
72
|
+
"ls",
|
|
73
|
+
]);
|
|
74
|
+
/**
|
|
75
|
+
* Canonical active tool-set handed to setActiveToolsByName at every gate:
|
|
76
|
+
* every model-callable operator action (dynamic, from the registry) PLUS the
|
|
77
|
+
* built-in explore tools (find/ls) PLUS the safe explore custom tools
|
|
78
|
+
* (safe-read / safe-grep / git-status / git-diff). bash is NOT here (M0-A).
|
|
79
|
+
* File-WRITING coding tools (edit/write/...) are never here — they are
|
|
80
|
+
* excluded at the registry level and thus cannot be activated.
|
|
81
|
+
*/
|
|
82
|
+
export const OPERATOR_CHAT_SAFE_EXPLORE_TOOL_IDS = Object.freeze([
|
|
83
|
+
"safe-read",
|
|
84
|
+
"safe-grep",
|
|
85
|
+
"git-status",
|
|
86
|
+
"git-diff",
|
|
87
|
+
]);
|
|
88
|
+
export function OPERATOR_CHAT_ACTIVE_TOOL_NAMES() {
|
|
89
|
+
return [
|
|
90
|
+
...OPERATOR_CHAT_ALLOWED_TOOLS,
|
|
91
|
+
...OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS,
|
|
92
|
+
...OPERATOR_CHAT_SAFE_EXPLORE_TOOL_IDS,
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Build Pi custom ToolDefinition objects for every whitelisted operator action.
|
|
97
|
+
* Each tool's execute() routes into the operator action dispatcher (Gate 3).
|
|
98
|
+
* Requires the `typebox` + `defineTool` packages (both bundled with the SDK).
|
|
99
|
+
*/
|
|
100
|
+
async function buildCustomOperatorTools(options) {
|
|
101
|
+
// typebox is a transitive dep of @earendil-works/pi-coding-agent; Node's
|
|
102
|
+
// resolver hoists to the SDK's nested copy. defineTool is re-exported by SDK.
|
|
103
|
+
const [{ Type }, { defineTool }] = await Promise.all([
|
|
104
|
+
import("typebox"),
|
|
105
|
+
import("@earendil-works/pi-coding-agent"),
|
|
106
|
+
]);
|
|
107
|
+
const schemas = buildModelCallableToolSchemas();
|
|
108
|
+
const ctx = options.actionContext;
|
|
109
|
+
return schemas.map((schema) => {
|
|
110
|
+
// TypeBox schema is built dynamically; cast through unknown to the builder
|
|
111
|
+
// since Static<TParams> is structural and cannot be statically inferred here.
|
|
112
|
+
const parameters = Type.Object(schema.inputParams.reduce((acc, param) => {
|
|
113
|
+
let t;
|
|
114
|
+
switch (param.type) {
|
|
115
|
+
case "string":
|
|
116
|
+
t = Type.String();
|
|
117
|
+
break;
|
|
118
|
+
case "boolean":
|
|
119
|
+
t = Type.Boolean();
|
|
120
|
+
break;
|
|
121
|
+
case "number":
|
|
122
|
+
t = Type.Number();
|
|
123
|
+
break;
|
|
124
|
+
case "array":
|
|
125
|
+
t = Type.Array(Type.String());
|
|
126
|
+
break;
|
|
127
|
+
default:
|
|
128
|
+
t = Type.Object({}, { additionalProperties: true });
|
|
129
|
+
}
|
|
130
|
+
acc[param.name] = param.required
|
|
131
|
+
? t
|
|
132
|
+
: Type.Optional(t);
|
|
133
|
+
return acc;
|
|
134
|
+
}, {}), { additionalProperties: false });
|
|
135
|
+
return defineTool({
|
|
136
|
+
name: schema.toolId,
|
|
137
|
+
label: schema.toolId,
|
|
138
|
+
description: `${schema.description} (kind=${schema.kind}; cli: ${schema.cli})`,
|
|
139
|
+
promptSnippet: `${schema.toolId}: ${schema.description}`,
|
|
140
|
+
parameters: parameters,
|
|
141
|
+
async execute(toolCallId, params) {
|
|
142
|
+
const decision = authorizeOperatorChatTool(schema.toolId);
|
|
143
|
+
if (!decision.ok) {
|
|
144
|
+
return {
|
|
145
|
+
content: [
|
|
146
|
+
{ type: "text", text: JSON.stringify({ error: decision.message, code: decision.code }) },
|
|
147
|
+
],
|
|
148
|
+
details: { error: decision.message, code: decision.code },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
// Defer require to avoid circular import at module load.
|
|
152
|
+
const { dispatchChatToolCall } = await import("./session-store.js");
|
|
153
|
+
const result = await dispatchChatToolCall(ctx, decision.toolId, params, toolCallId);
|
|
154
|
+
const payload = result.ok
|
|
155
|
+
? result.result
|
|
156
|
+
: { error: result.message, code: result.errorCode };
|
|
157
|
+
return {
|
|
158
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
159
|
+
details: payload,
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Build the safe explore custom tools (roadmap M0-A / G16): safe-read,
|
|
167
|
+
* safe-grep, gitStatus, gitDiff. These replace the bare SDK bash/read/grep so
|
|
168
|
+
* the model can probe the repo WITHOUT write escapes (echo redirect, tee,
|
|
169
|
+
* sed -i, node -e) and WITHOUT reading sensitive files (env files, key/pem,
|
|
170
|
+
* auth.json, sessions dir) — and every returned byte is secret-scrubbed
|
|
171
|
+
* before reaching the model context.
|
|
172
|
+
*
|
|
173
|
+
* gitStatus/gitDiff use a FIXED argv (typed, no shell) so they cannot be
|
|
174
|
+
* turned into an arbitrary-shell vector.
|
|
175
|
+
*/
|
|
176
|
+
async function buildExploreCustomTools(options) {
|
|
177
|
+
const [{ Type }, { defineTool }] = await Promise.all([
|
|
178
|
+
import("typebox"),
|
|
179
|
+
import("@earendil-works/pi-coding-agent"),
|
|
180
|
+
]);
|
|
181
|
+
const { execFileSync } = await import("node:child_process");
|
|
182
|
+
const repoRoot = options.repoRoot;
|
|
183
|
+
const { safeReadFile, safeGrep, scrubSecrets } = await import("./explore-tools.js");
|
|
184
|
+
function safeGit(args) {
|
|
185
|
+
try {
|
|
186
|
+
const stdout = execFileSync("git", args, {
|
|
187
|
+
cwd: repoRoot,
|
|
188
|
+
encoding: "utf8",
|
|
189
|
+
// Fixed argv, no shell, bounded output.
|
|
190
|
+
maxBuffer: 512 * 1024,
|
|
191
|
+
timeout: 10_000,
|
|
192
|
+
windowsHide: true,
|
|
193
|
+
});
|
|
194
|
+
return { ok: true, stdout };
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
code: "GIT_FAILED",
|
|
200
|
+
message: e instanceof Error ? e.message : String(e),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const safeReadTool = defineTool({
|
|
205
|
+
name: "safe-read",
|
|
206
|
+
label: "safe-read",
|
|
207
|
+
description: "Read a repo-relative file with sensitive-file denylist (.env*/*.key/*.pem/.git/**/auth.json/sessions/**) and secret scrubbing. Absolute paths and .. are rejected.",
|
|
208
|
+
promptSnippet: "safe-read: read a repo file (safe; secrets scrubbed)",
|
|
209
|
+
parameters: Type.Object({
|
|
210
|
+
path: Type.String({ description: "repo-relative file path" }),
|
|
211
|
+
}, { additionalProperties: false }),
|
|
212
|
+
async execute(_toolCallId, params) {
|
|
213
|
+
const raw = String(params.path ?? "");
|
|
214
|
+
try {
|
|
215
|
+
const result = await safeReadFile(repoRoot, raw);
|
|
216
|
+
return {
|
|
217
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
218
|
+
details: result,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
const err = e;
|
|
223
|
+
return {
|
|
224
|
+
content: [
|
|
225
|
+
{
|
|
226
|
+
type: "text",
|
|
227
|
+
text: JSON.stringify({
|
|
228
|
+
error: err.message ?? String(e),
|
|
229
|
+
code: err.code ?? "READ_FAILED",
|
|
230
|
+
}),
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
details: { error: err.message ?? String(e), code: err.code ?? "READ_FAILED" },
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
const safeGrepTool = defineTool({
|
|
239
|
+
name: "safe-grep",
|
|
240
|
+
label: "safe-grep",
|
|
241
|
+
description: "Line search across the repo working tree with sensitive-file denylist and secret scrubbing. pattern is a JS regex source; optional glob filters paths.",
|
|
242
|
+
promptSnippet: "safe-grep: search repo files (safe; secrets scrubbed)",
|
|
243
|
+
parameters: Type.Object({
|
|
244
|
+
pattern: Type.String({ description: "regex source (no shell)" }),
|
|
245
|
+
caseInsensitive: Type.Optional(Type.Boolean()),
|
|
246
|
+
glob: Type.Optional(Type.String({ description: "optional glob filter e.g. *.ts" })),
|
|
247
|
+
}, { additionalProperties: false }),
|
|
248
|
+
async execute(_toolCallId, params) {
|
|
249
|
+
try {
|
|
250
|
+
const result = await safeGrep(repoRoot, String(params.pattern ?? ""), {
|
|
251
|
+
caseInsensitive: Boolean(params.caseInsensitive),
|
|
252
|
+
glob: params.glob ? String(params.glob) : undefined,
|
|
253
|
+
});
|
|
254
|
+
return {
|
|
255
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
256
|
+
details: result,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
catch (e) {
|
|
260
|
+
const err = e;
|
|
261
|
+
return {
|
|
262
|
+
content: [
|
|
263
|
+
{
|
|
264
|
+
type: "text",
|
|
265
|
+
text: JSON.stringify({
|
|
266
|
+
error: err.message ?? String(e),
|
|
267
|
+
code: err.code ?? "GREP_FAILED",
|
|
268
|
+
}),
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
details: { error: err.message ?? String(e), code: err.code ?? "GREP_FAILED" },
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
const gitStatusTool = defineTool({
|
|
277
|
+
name: "git-status",
|
|
278
|
+
label: "git-status",
|
|
279
|
+
description: "Typed `git status --porcelain` (fixed argv, no shell). Returns scrubbed working-tree status.",
|
|
280
|
+
promptSnippet: "git-status: working tree status (read-only)",
|
|
281
|
+
parameters: Type.Object({}, { additionalProperties: false }),
|
|
282
|
+
async execute() {
|
|
283
|
+
const r = safeGit(["status", "--porcelain"]);
|
|
284
|
+
if (!r.ok) {
|
|
285
|
+
return {
|
|
286
|
+
content: [{ type: "text", text: JSON.stringify({ error: r.message, code: r.code }) }],
|
|
287
|
+
details: { error: r.message, code: r.code },
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
const { scrubbed, redactions } = scrubSecrets(r.stdout);
|
|
291
|
+
const payload = { status: scrubbed, redactions };
|
|
292
|
+
return {
|
|
293
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
294
|
+
details: payload,
|
|
295
|
+
};
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
const gitDiffTool = defineTool({
|
|
299
|
+
name: "git-diff",
|
|
300
|
+
label: "git-diff",
|
|
301
|
+
description: "Typed `git diff` (fixed argv: --stat or a single -- path; no shell). Returns scrubbed diff. path must be repo-relative and not sensitive.",
|
|
302
|
+
promptSnippet: "git-diff: diff (read-only, scrubbed)",
|
|
303
|
+
parameters: Type.Object({
|
|
304
|
+
path: Type.Optional(Type.String({ description: "optional repo-relative path to diff (default: whole tree --stat)" })),
|
|
305
|
+
}, { additionalProperties: false }),
|
|
306
|
+
async execute(_toolCallId, params) {
|
|
307
|
+
let rawPath = params.path ? String(params.path) : undefined;
|
|
308
|
+
// If a path is supplied, validate it via the read-path guard so git diff
|
|
309
|
+
// cannot be aimed at sensitive files (.env* / auth.json / sessions).
|
|
310
|
+
if (rawPath) {
|
|
311
|
+
const { guardReadPath } = await import("./explore-tools.js");
|
|
312
|
+
const g = await guardReadPath(repoRoot, rawPath);
|
|
313
|
+
if (!g.ok) {
|
|
314
|
+
return {
|
|
315
|
+
content: [
|
|
316
|
+
{ type: "text", text: JSON.stringify({ error: g.message, code: g.code }) },
|
|
317
|
+
],
|
|
318
|
+
details: { error: g.message, code: g.code },
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
rawPath = g.repoRelative;
|
|
322
|
+
}
|
|
323
|
+
const args = rawPath
|
|
324
|
+
? ["diff", "--", rawPath]
|
|
325
|
+
: ["diff", "--stat"];
|
|
326
|
+
const r = safeGit(args);
|
|
327
|
+
if (!r.ok) {
|
|
328
|
+
return {
|
|
329
|
+
content: [{ type: "text", text: JSON.stringify({ error: r.message, code: r.code }) }],
|
|
330
|
+
details: { error: r.message, code: r.code },
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
const { scrubbed, redactions } = scrubSecrets(r.stdout);
|
|
334
|
+
const payload = { diff: scrubbed, redactions };
|
|
335
|
+
return {
|
|
336
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
337
|
+
details: payload,
|
|
338
|
+
};
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
return [safeReadTool, safeGrepTool, gitStatusTool, gitDiffTool];
|
|
342
|
+
}
|
|
343
|
+
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
344
|
+
/**
|
|
345
|
+
* Extract the latest assistant text fragment from an SDK message object.
|
|
346
|
+
* Tolerant of multiple message shapes the SDK emits.
|
|
347
|
+
*/
|
|
348
|
+
export function extractAssistantText(message) {
|
|
349
|
+
if (!message || typeof message !== "object")
|
|
350
|
+
return "";
|
|
351
|
+
const m = message;
|
|
352
|
+
if (typeof m.text === "string")
|
|
353
|
+
return m.text;
|
|
354
|
+
const contents = Array.isArray(m.content) ? m.content : [];
|
|
355
|
+
const parts = Array.isArray(m.parts) ? m.parts : [];
|
|
356
|
+
const segments = [];
|
|
357
|
+
for (const c of [...contents, ...parts]) {
|
|
358
|
+
if (!c || typeof c !== "object")
|
|
359
|
+
continue;
|
|
360
|
+
const seg = c;
|
|
361
|
+
if (typeof seg.text === "string")
|
|
362
|
+
segments.push(seg.text);
|
|
363
|
+
}
|
|
364
|
+
return segments.join("");
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Console Pi runtime — holds Chat sessions and drives prompt() with three-gate
|
|
368
|
+
* tool enforcement. Session-per-sessionId; cross-request reuse via open().
|
|
369
|
+
*/
|
|
370
|
+
export class ConsolePiRuntime {
|
|
371
|
+
options;
|
|
372
|
+
loader;
|
|
373
|
+
sessions = new Map();
|
|
374
|
+
modelRuntimes = new Map();
|
|
375
|
+
sessionManagers = new Map();
|
|
376
|
+
mainlineLeaves = new Map();
|
|
377
|
+
bindings;
|
|
378
|
+
disabledInstructionSkills = new Set();
|
|
379
|
+
constructor(options) {
|
|
380
|
+
this.options = options;
|
|
381
|
+
this.loader = createOperatorChatResourceLoader();
|
|
382
|
+
this.bindings =
|
|
383
|
+
options.bindings ?? createDefaultPiSdkBindings();
|
|
384
|
+
}
|
|
385
|
+
get sessionDir() {
|
|
386
|
+
return this.options.sessionDir;
|
|
387
|
+
}
|
|
388
|
+
/** Resolve the Pi agent directory for bounded Console configuration APIs. */
|
|
389
|
+
async getAgentDir() {
|
|
390
|
+
return safeGetAgentDir(this.bindings);
|
|
391
|
+
}
|
|
392
|
+
async testConfiguredModel(model) {
|
|
393
|
+
const { services } = await this.bindings.createServices({ cwd: this.options.cwd, agentDir: await this.getAgentDir() });
|
|
394
|
+
return { available: Boolean(this.bindings.resolveModel({ modelRuntime: services.modelRuntime, provider: model.provider, modelId: model.modelId })) };
|
|
395
|
+
}
|
|
396
|
+
setDisabledInstructionSkills(names) {
|
|
397
|
+
this.disabledInstructionSkills = new Set(names);
|
|
398
|
+
}
|
|
399
|
+
/** Operator action context used by custom-tool dispatchers (Gate 3). */
|
|
400
|
+
get actionContext() {
|
|
401
|
+
return this.options.actionContext;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Resolve the Chat default model descriptor from harness.json
|
|
405
|
+
* executors.pi.MED + the SDK available-model list. Returns undefined when
|
|
406
|
+
* harness is unset or no provider surfaces the model. No hardcoded fallback.
|
|
407
|
+
*/
|
|
408
|
+
async resolveDefaultModel() {
|
|
409
|
+
let available;
|
|
410
|
+
try {
|
|
411
|
+
// Build a throwaway services just to read modelRuntime.snapshot. This is
|
|
412
|
+
// cheap (SDK caches); tests inject bindings that stub listAvailableModels.
|
|
413
|
+
const { services } = await this.bindings.createServices({
|
|
414
|
+
cwd: this.options.cwd,
|
|
415
|
+
agentDir: await safeGetAgentDir(this.bindings),
|
|
416
|
+
});
|
|
417
|
+
available = this.bindings.listAvailableModels({
|
|
418
|
+
modelRuntime: services.modelRuntime,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
const descriptor = await resolveDefaultChatModel(this.options.cwd, available);
|
|
425
|
+
if (!descriptor)
|
|
426
|
+
return undefined;
|
|
427
|
+
return {
|
|
428
|
+
provider: descriptor.provider,
|
|
429
|
+
modelId: descriptor.modelId,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Load the operator-context instruction skills (plan D1) and compose the
|
|
434
|
+
* read-only methodology fragment for the Chat system prompt. Returns an
|
|
435
|
+
* empty string when no skills load (e.g. skills dir missing) so the
|
|
436
|
+
* session still boots — skills are context, not a hard dependency.
|
|
437
|
+
*/
|
|
438
|
+
async composeSystemPromptSkills() {
|
|
439
|
+
try {
|
|
440
|
+
const result = await loadOperatorChatInstructionSkills(this.options.skillsDir, OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS.filter((name) => !this.disabledInstructionSkills.has(name)));
|
|
441
|
+
return composeInstructionSkillsPrompt(result.loaded);
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
process.stderr.write(`[console] chat instruction-skills load failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
445
|
+
return "";
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/** Create a new isolated Chat session pinned to the operator-chat tool set. */
|
|
449
|
+
async createSession(init) {
|
|
450
|
+
const sessionId = `operator-chat-${randomBytes(12).toString("hex")}`;
|
|
451
|
+
const agentDir = await safeGetAgentDir(this.bindings);
|
|
452
|
+
// Resolve default model from harness.json executors.pi.MED (no hardcode).
|
|
453
|
+
// Caller may pass an explicit model; otherwise we read the repo harness and
|
|
454
|
+
// search the SDK available-model list for a credentialled provider.
|
|
455
|
+
let model = init?.model;
|
|
456
|
+
if (!model) {
|
|
457
|
+
const resolved = await this.resolveDefaultModel();
|
|
458
|
+
if (resolved)
|
|
459
|
+
model = resolved;
|
|
460
|
+
}
|
|
461
|
+
// Final fallback only when nothing resolved: let the SDK pick its default
|
|
462
|
+
// (we do NOT hardcode a provider here).
|
|
463
|
+
// Load operator-context instruction skills (plan D1) and compose the
|
|
464
|
+
// read-only methodology fragment injected into the Chat system prompt.
|
|
465
|
+
// Skills are NEVER registered as executable tools (D1 hard rule).
|
|
466
|
+
const skillFragment = await this.composeSystemPromptSkills();
|
|
467
|
+
const appendSystemPrompt = [
|
|
468
|
+
OPERATOR_CHAT_SYSTEM_PROMPT_BASE,
|
|
469
|
+
...(skillFragment ? [skillFragment] : []),
|
|
470
|
+
...(init?.systemPromptSuffix ? [init.systemPromptSuffix] : []),
|
|
471
|
+
];
|
|
472
|
+
const { services } = await this.bindings.createServices({
|
|
473
|
+
cwd: this.options.cwd,
|
|
474
|
+
agentDir,
|
|
475
|
+
resourceLoaderOptions: {
|
|
476
|
+
// Closed surface: do not load user/project extensions, skills,
|
|
477
|
+
// AGENTS.md, context files. The Chat tool surface is fixed by
|
|
478
|
+
// the whitelist, not by filesystem discovery.
|
|
479
|
+
noContextFiles: true,
|
|
480
|
+
noSkills: true,
|
|
481
|
+
noExtensions: true,
|
|
482
|
+
// Inject the audited instruction skills as READ-ONLY context
|
|
483
|
+
// (plan D1). appendSystemPrompt is the SDK's documented entry
|
|
484
|
+
// point for adding system-prompt text without enabling tools.
|
|
485
|
+
appendSystemPrompt,
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
const modelRuntime = services.modelRuntime;
|
|
489
|
+
// Resolve the concrete SDK model only when we have a descriptor. When no
|
|
490
|
+
// model is resolved (harness unset + no snapshot match), we omit `model`
|
|
491
|
+
// and let the SDK session use its own default (no hardcoded fallback).
|
|
492
|
+
const resolvedModel = model
|
|
493
|
+
? (this.bindings.resolveModel({
|
|
494
|
+
modelRuntime,
|
|
495
|
+
provider: model.provider,
|
|
496
|
+
modelId: model.modelId,
|
|
497
|
+
}) ?? undefined)
|
|
498
|
+
: undefined;
|
|
499
|
+
const sessionManager = await this.bindings.createSessionManager({
|
|
500
|
+
cwd: this.options.cwd,
|
|
501
|
+
sessionDir: this.options.sessionDir,
|
|
502
|
+
sessionId,
|
|
503
|
+
});
|
|
504
|
+
// Gate 1: pin the tool set at session create. assertNoWriteTool guards
|
|
505
|
+
// that no file-WRITING coding tool slipped into the allowed list (bash
|
|
506
|
+
// is intentionally allowed since the 2026-07-25 widening).
|
|
507
|
+
assertNoWriteToolInList(OPERATOR_CHAT_ALLOWED_TOOLS);
|
|
508
|
+
// Build custom Pi tools that route whitelisted operator actions into
|
|
509
|
+
// the dispatcher (Gate 3 lives inside each tool's execute()).
|
|
510
|
+
let operatorCustomTools = [];
|
|
511
|
+
if (this.options.actionContext) {
|
|
512
|
+
try {
|
|
513
|
+
operatorCustomTools = await buildCustomOperatorTools({
|
|
514
|
+
actionContext: this.options.actionContext,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
// If custom-tool build fails (e.g. typebox unavailable), fall back to
|
|
519
|
+
// a tool-less session — Chat still works as text-only, and the
|
|
520
|
+
// failure is surfaced via doctor / readiness diagnostics.
|
|
521
|
+
process.stderr.write(`[console] chat custom-tools build failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
// M0-A: also register the safe explore tools (safe-read / safe-grep /
|
|
525
|
+
// git-status / git-diff) so the model can probe the repo without bash.
|
|
526
|
+
let exploreCustomTools = [];
|
|
527
|
+
try {
|
|
528
|
+
exploreCustomTools = await buildExploreCustomTools({
|
|
529
|
+
repoRoot: this.options.cwd,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
process.stderr.write(`[console] chat explore-tools build failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
534
|
+
}
|
|
535
|
+
const customTools = [...operatorCustomTools, ...exploreCustomTools];
|
|
536
|
+
// Tool surface (roadmap M0):
|
|
537
|
+
// - ALL model-callable operator actions are registered as customTools.
|
|
538
|
+
// - Safe explore tools (safe-read / safe-grep / git-status / git-diff)
|
|
539
|
+
// are also registered as customTools (bash is GONE — M0-A removes
|
|
540
|
+
// the write-via-redirect escape).
|
|
541
|
+
// - Built-in find/ls are registered by passing `tools:` (Gate 1). The
|
|
542
|
+
// default builtin read/bash/edit/write are EXCLUDED: read/bash/grep
|
|
543
|
+
// are replaced by the safe custom versions above; edit/write stay
|
|
544
|
+
// excluded (file-writing never activated).
|
|
545
|
+
// by passing `tools: OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS` (Gate 1).
|
|
546
|
+
// This is REQUIRED: the SDK's default builtin set is only
|
|
547
|
+
// `read, bash, edit, write` (grep/find/ls are NOT default builtins —
|
|
548
|
+
// see SDK CreateAgentSessionOptions.tools docs). Without `tools`, the
|
|
549
|
+
const { session } = await this.bindings.createSessionFromServices({
|
|
550
|
+
services,
|
|
551
|
+
sessionManager,
|
|
552
|
+
// Explicitly register the explore-tool builtin set (Gate 1): find/ls.
|
|
553
|
+
// The default builtin read/bash/edit/write are EXCLUDED below; read/
|
|
554
|
+
// grep/bash are replaced by the safe customTools (M0-A).
|
|
555
|
+
tools: [...OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS],
|
|
556
|
+
excludeTools: [
|
|
557
|
+
"edit",
|
|
558
|
+
"write",
|
|
559
|
+
"apply_patch",
|
|
560
|
+
"apply-patch",
|
|
561
|
+
"full-tools",
|
|
562
|
+
"full_tools",
|
|
563
|
+
"coding-chat",
|
|
564
|
+
"coding_chat",
|
|
565
|
+
"shell",
|
|
566
|
+
// M0-A: bash is removed entirely (write-via-redirect escape). The
|
|
567
|
+
// model gets safe-read / safe-grep custom tools instead.
|
|
568
|
+
"bash",
|
|
569
|
+
// M0-A: built-in read/grep are replaced by custom safe-read/safe-grep
|
|
570
|
+
// that enforce a repo-relative boundary, a sensitive-file denylist,
|
|
571
|
+
// and secret scrubbing (roadmap G16).
|
|
572
|
+
"read",
|
|
573
|
+
"grep",
|
|
574
|
+
],
|
|
575
|
+
...(customTools && customTools.length > 0
|
|
576
|
+
? { customTools }
|
|
577
|
+
: {}),
|
|
578
|
+
...(resolvedModel ? { model: resolvedModel } : {}),
|
|
579
|
+
});
|
|
580
|
+
// Gate 2: re-pin active tools (defensive — some SDK paths may seed
|
|
581
|
+
// defaults). Active set = all operator actions + built-in explore tools.
|
|
582
|
+
// File-writing tools are excluded above and thus cannot appear here.
|
|
583
|
+
session.setActiveToolsByName(OPERATOR_CHAT_ACTIVE_TOOL_NAMES());
|
|
584
|
+
this.sessions.set(sessionId, session);
|
|
585
|
+
this.sessionManagers.set(sessionId, sessionManager);
|
|
586
|
+
this.modelRuntimes.set(sessionId, modelRuntime);
|
|
587
|
+
return {
|
|
588
|
+
sessionId,
|
|
589
|
+
sessionFile: session.sessionFile,
|
|
590
|
+
createdAt: new Date().toISOString(),
|
|
591
|
+
model,
|
|
592
|
+
activeTools: session.getActiveToolNames(),
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
async forkSession(init) {
|
|
596
|
+
if (!this.bindings.forkSessionManager)
|
|
597
|
+
throw new Error("SESSION_FORK_UNSUPPORTED");
|
|
598
|
+
const sessionId = `operator-chat-${randomBytes(12).toString("hex")}`;
|
|
599
|
+
const sessionManager = await this.bindings.forkSessionManager({
|
|
600
|
+
sourcePath: init.sourceSessionFile,
|
|
601
|
+
targetCwd: this.options.cwd,
|
|
602
|
+
targetSessionDir: this.options.sessionDir,
|
|
603
|
+
targetSessionId: sessionId,
|
|
604
|
+
});
|
|
605
|
+
const built = await this.buildSessionWithServices({ sessionManager, model: init.model });
|
|
606
|
+
built.session.setActiveToolsByName(OPERATOR_CHAT_ACTIVE_TOOL_NAMES());
|
|
607
|
+
this.sessions.set(sessionId, built.session);
|
|
608
|
+
this.sessionManagers.set(sessionId, sessionManager);
|
|
609
|
+
this.modelRuntimes.set(sessionId, built.modelRuntime);
|
|
610
|
+
return { sessionId, sessionFile: built.session.sessionFile, createdAt: new Date().toISOString(), model: init.model, activeTools: built.session.getActiveToolNames() };
|
|
611
|
+
}
|
|
612
|
+
async branchSession(input) {
|
|
613
|
+
const session = this.sessions.get(input.sessionId);
|
|
614
|
+
const manager = this.sessionManagers.get(input.sessionId);
|
|
615
|
+
if (!session || !manager)
|
|
616
|
+
throw new Error(`chat session not active: ${input.sessionId}`);
|
|
617
|
+
if (session.isStreaming === true || session.isIdle === false)
|
|
618
|
+
throw new Error("CHAT_TURN_ACTIVE");
|
|
619
|
+
if (!manager.getEntry?.(input.branchFromEntryId))
|
|
620
|
+
throw new Error("BRANCH_ENTRY_NOT_FOUND");
|
|
621
|
+
if (!this.mainlineLeaves.has(input.sessionId))
|
|
622
|
+
this.mainlineLeaves.set(input.sessionId, manager.getLeafId?.());
|
|
623
|
+
const entry = input.summary
|
|
624
|
+
? manager.branchWithSummary?.(input.branchFromEntryId, input.summary)
|
|
625
|
+
: (manager.branch(input.branchFromEntryId), undefined);
|
|
626
|
+
return { branchedFromEntryId: input.branchFromEntryId, ...(entry?.id ? { branchSummaryEntryId: entry.id } : {}) };
|
|
627
|
+
}
|
|
628
|
+
switchToMainline(sessionId) {
|
|
629
|
+
const manager = this.sessionManagers.get(sessionId);
|
|
630
|
+
if (!manager)
|
|
631
|
+
throw new Error(`chat session not active: ${sessionId}`);
|
|
632
|
+
manager.branch(this.mainlineLeaves.get(sessionId));
|
|
633
|
+
this.mainlineLeaves.delete(sessionId);
|
|
634
|
+
}
|
|
635
|
+
getBranchContext(sessionId) {
|
|
636
|
+
const manager = this.sessionManagers.get(sessionId);
|
|
637
|
+
if (!manager)
|
|
638
|
+
throw new Error(`chat session not active: ${sessionId}`);
|
|
639
|
+
return { leafId: manager.getLeafId?.() ?? null, entries: manager.getBranch?.() ?? [] };
|
|
640
|
+
}
|
|
641
|
+
async listModels(sessionId) {
|
|
642
|
+
const modelRuntime = this.modelRuntimes.get(sessionId);
|
|
643
|
+
if (!this.sessions.has(sessionId) || !modelRuntime)
|
|
644
|
+
throw new Error(`chat session not active: ${sessionId}`);
|
|
645
|
+
return this.bindings.listAvailableModels({ modelRuntime });
|
|
646
|
+
}
|
|
647
|
+
async applyModel(sessionId, model) {
|
|
648
|
+
const session = this.sessions.get(sessionId);
|
|
649
|
+
const modelRuntime = this.modelRuntimes.get(sessionId);
|
|
650
|
+
if (!session || !modelRuntime)
|
|
651
|
+
throw new Error(`chat session not active: ${sessionId}`);
|
|
652
|
+
if (!session.setModel)
|
|
653
|
+
throw new Error("MODEL_SWITCH_UNSUPPORTED");
|
|
654
|
+
const resolved = this.bindings.resolveModel({ modelRuntime, provider: model.provider, modelId: model.modelId });
|
|
655
|
+
if (!resolved)
|
|
656
|
+
throw new Error("MODEL_NOT_AVAILABLE");
|
|
657
|
+
await session.setModel(resolved);
|
|
658
|
+
return { provider: session.model?.provider ?? model.provider, modelId: session.model?.id ?? session.model?.modelId ?? model.modelId };
|
|
659
|
+
}
|
|
660
|
+
applyThinkingLevel(sessionId, level) {
|
|
661
|
+
const session = this.sessions.get(sessionId);
|
|
662
|
+
if (!session)
|
|
663
|
+
throw new Error(`chat session not active: ${sessionId}`);
|
|
664
|
+
if (!THINKING_LEVELS.includes(level) || !session.setThinkingLevel)
|
|
665
|
+
throw new Error("THINKING_LEVEL_UNSUPPORTED");
|
|
666
|
+
session.setThinkingLevel(level);
|
|
667
|
+
return session.thinkingLevel ?? level;
|
|
668
|
+
}
|
|
669
|
+
getRuntimeSelection(sessionId) {
|
|
670
|
+
const session = this.sessions.get(sessionId);
|
|
671
|
+
if (!session)
|
|
672
|
+
return { activeTools: [] };
|
|
673
|
+
const provider = session.model?.provider;
|
|
674
|
+
const modelId = session.model?.id ?? session.model?.modelId;
|
|
675
|
+
return { ...(provider && modelId ? { model: { provider, modelId } } : {}), ...(session.thinkingLevel ? { thinkingLevel: session.thinkingLevel } : {}), activeTools: session.getActiveToolNames() };
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Re-open a previously persisted Chat session after Console restart or
|
|
679
|
+
* browser refresh (roadmap M1-S04 / S05).
|
|
680
|
+
*
|
|
681
|
+
* Unlike `createSession`, this reuses the EXISTING Pi JSONL session file so
|
|
682
|
+
* the conversation history is preserved. The SDK's `SessionManager.open(path)`
|
|
683
|
+
* loads the file; we then rebuild the same services + pinned tool surface so
|
|
684
|
+
* the operator-chat three-gate policy still applies after a restart.
|
|
685
|
+
*
|
|
686
|
+
* Fail-closed contract: if `sessionFile` is missing, or the SDK cannot open
|
|
687
|
+
* it (file deleted/corrupt), returns an `{ ok: false, code }` result rather
|
|
688
|
+
* than silently creating a fresh empty session and losing prior context.
|
|
689
|
+
*/
|
|
690
|
+
async reopenSession(init) {
|
|
691
|
+
const sessionId = init.sessionId;
|
|
692
|
+
// Already active in-memory: idempotent reopen is a no-op success.
|
|
693
|
+
if (this.sessions.has(sessionId)) {
|
|
694
|
+
return { ok: true, handle: await this.snapshotActive(sessionId) };
|
|
695
|
+
}
|
|
696
|
+
if (!init.sessionFile) {
|
|
697
|
+
return {
|
|
698
|
+
ok: false,
|
|
699
|
+
code: "NO_SESSION_FILE",
|
|
700
|
+
message: `cannot reopen chat session ${sessionId}: no persisted Pi session file`,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
let sessionManager;
|
|
704
|
+
try {
|
|
705
|
+
sessionManager = await this.bindings.openSessionManager({
|
|
706
|
+
sessionFile: init.sessionFile,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
catch (error) {
|
|
710
|
+
return {
|
|
711
|
+
ok: false,
|
|
712
|
+
code: "REOPEN_FAILED",
|
|
713
|
+
message: error instanceof Error ? error.message : String(error),
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
if (!sessionManager) {
|
|
717
|
+
return {
|
|
718
|
+
ok: false,
|
|
719
|
+
code: "REOPEN_FAILED",
|
|
720
|
+
message: `Pi session file not openable: ${init.sessionFile}`,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
try {
|
|
724
|
+
const built = await this.buildSessionWithServices({
|
|
725
|
+
sessionManager,
|
|
726
|
+
model: init.model,
|
|
727
|
+
systemPromptSuffix: init.systemPromptSuffix,
|
|
728
|
+
});
|
|
729
|
+
const session = built.session;
|
|
730
|
+
// The reopened session MUST report the SAME sessionId as the durable
|
|
731
|
+
// record, otherwise we'd be serving a different conversation under a
|
|
732
|
+
// stale id. Mismatch is a fail-closed reopen failure.
|
|
733
|
+
if (session.sessionId !== sessionId) {
|
|
734
|
+
try {
|
|
735
|
+
session.dispose();
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
// ignore
|
|
739
|
+
}
|
|
740
|
+
return {
|
|
741
|
+
ok: false,
|
|
742
|
+
code: "REOPEN_FAILED",
|
|
743
|
+
message: `reopened Pi sessionId mismatch: expected ${sessionId}, got ${session.sessionId}`,
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
session.setActiveToolsByName(OPERATOR_CHAT_ACTIVE_TOOL_NAMES());
|
|
747
|
+
this.sessions.set(sessionId, session);
|
|
748
|
+
this.sessionManagers.set(sessionId, sessionManager);
|
|
749
|
+
this.modelRuntimes.set(sessionId, built.modelRuntime);
|
|
750
|
+
return {
|
|
751
|
+
ok: true,
|
|
752
|
+
handle: {
|
|
753
|
+
sessionId,
|
|
754
|
+
sessionFile: session.sessionFile ?? init.sessionFile,
|
|
755
|
+
createdAt: new Date().toISOString(),
|
|
756
|
+
model: init.model,
|
|
757
|
+
activeTools: session.getActiveToolNames(),
|
|
758
|
+
},
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
catch (error) {
|
|
762
|
+
return {
|
|
763
|
+
ok: false,
|
|
764
|
+
code: "REOPEN_FAILED",
|
|
765
|
+
message: error instanceof Error ? error.message : String(error),
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
/** Build a handle for an already-active session (idempotent reopen path). */
|
|
770
|
+
async snapshotActive(sessionId) {
|
|
771
|
+
const session = this.sessions.get(sessionId);
|
|
772
|
+
return {
|
|
773
|
+
sessionId,
|
|
774
|
+
sessionFile: session.sessionFile,
|
|
775
|
+
createdAt: new Date().toISOString(),
|
|
776
|
+
model: undefined,
|
|
777
|
+
activeTools: session.getActiveToolNames(),
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Shared services + pinned-tool materialization for both createSession and
|
|
782
|
+
* reopenSession. Loads closed-surface services, resolves the model, builds
|
|
783
|
+
* the operator + safe explore customTools, and calls the SDK with the same
|
|
784
|
+
* excludeTools / tools policy (M0-A three-gate).
|
|
785
|
+
*/
|
|
786
|
+
async buildSessionWithServices(init) {
|
|
787
|
+
const agentDir = await safeGetAgentDir(this.bindings);
|
|
788
|
+
const skillFragment = await this.composeSystemPromptSkills();
|
|
789
|
+
const appendSystemPrompt = [
|
|
790
|
+
OPERATOR_CHAT_SYSTEM_PROMPT_BASE,
|
|
791
|
+
...(skillFragment ? [skillFragment] : []),
|
|
792
|
+
...(init.systemPromptSuffix ? [init.systemPromptSuffix] : []),
|
|
793
|
+
];
|
|
794
|
+
const { services } = await this.bindings.createServices({
|
|
795
|
+
cwd: this.options.cwd,
|
|
796
|
+
agentDir,
|
|
797
|
+
resourceLoaderOptions: {
|
|
798
|
+
noContextFiles: true,
|
|
799
|
+
noSkills: true,
|
|
800
|
+
noExtensions: true,
|
|
801
|
+
appendSystemPrompt,
|
|
802
|
+
},
|
|
803
|
+
});
|
|
804
|
+
const modelRuntime = services.modelRuntime;
|
|
805
|
+
const resolvedModel = init.model
|
|
806
|
+
? (this.bindings.resolveModel({
|
|
807
|
+
modelRuntime,
|
|
808
|
+
provider: init.model.provider,
|
|
809
|
+
modelId: init.model.modelId,
|
|
810
|
+
}) ?? undefined)
|
|
811
|
+
: undefined;
|
|
812
|
+
assertNoWriteToolInList(OPERATOR_CHAT_ALLOWED_TOOLS);
|
|
813
|
+
let operatorCustomTools = [];
|
|
814
|
+
if (this.options.actionContext) {
|
|
815
|
+
try {
|
|
816
|
+
operatorCustomTools = await buildCustomOperatorTools({
|
|
817
|
+
actionContext: this.options.actionContext,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
catch (error) {
|
|
821
|
+
process.stderr.write(`[console] chat custom-tools build failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
let exploreCustomTools = [];
|
|
825
|
+
try {
|
|
826
|
+
exploreCustomTools = await buildExploreCustomTools({
|
|
827
|
+
repoRoot: this.options.cwd,
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
catch (error) {
|
|
831
|
+
process.stderr.write(`[console] chat explore-tools build failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
832
|
+
}
|
|
833
|
+
const customTools = [...operatorCustomTools, ...exploreCustomTools];
|
|
834
|
+
const { session } = await this.bindings.createSessionFromServices({
|
|
835
|
+
services,
|
|
836
|
+
sessionManager: init.sessionManager,
|
|
837
|
+
tools: [...OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS],
|
|
838
|
+
excludeTools: [
|
|
839
|
+
"edit",
|
|
840
|
+
"write",
|
|
841
|
+
"apply_patch",
|
|
842
|
+
"apply-patch",
|
|
843
|
+
"full-tools",
|
|
844
|
+
"full_tools",
|
|
845
|
+
"coding-chat",
|
|
846
|
+
"coding_chat",
|
|
847
|
+
"shell",
|
|
848
|
+
"bash",
|
|
849
|
+
"read",
|
|
850
|
+
"grep",
|
|
851
|
+
],
|
|
852
|
+
...(customTools && customTools.length > 0 ? { customTools } : {}),
|
|
853
|
+
...(resolvedModel ? { model: resolvedModel } : {}),
|
|
854
|
+
});
|
|
855
|
+
return { session, modelRuntime };
|
|
856
|
+
}
|
|
857
|
+
/** Re-open a previously created session (cross-request). */
|
|
858
|
+
open(sessionId) {
|
|
859
|
+
return this.sessions.get(sessionId);
|
|
860
|
+
}
|
|
861
|
+
hasSession(sessionId) {
|
|
862
|
+
return this.sessions.has(sessionId);
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Send a prompt and stream events. Gate 3: each tool invocation is
|
|
866
|
+
* re-authorized by authorizeOperatorChatTool before the dispatcher runs it
|
|
867
|
+
* (the dispatcher is wired by the HTTP layer, see chat-session.ts).
|
|
868
|
+
*/
|
|
869
|
+
async prompt(sessionId, text, onEvent, options) {
|
|
870
|
+
const session = this.sessions.get(sessionId);
|
|
871
|
+
if (!session) {
|
|
872
|
+
throw new Error(`chat session not found: ${sessionId}`);
|
|
873
|
+
}
|
|
874
|
+
// Gate 2 (re-pin before each turn): interview turns pass through the
|
|
875
|
+
// existing closed interview allow/deny registry. The next ordinary turn
|
|
876
|
+
// explicitly restores the normal Operator Chat set.
|
|
877
|
+
const requestedTools = OPERATOR_CHAT_ACTIVE_TOOL_NAMES();
|
|
878
|
+
const activeTools = options?.mode === "requirement-interview"
|
|
879
|
+
? filterActiveInterviewTools(requestedTools).allowed
|
|
880
|
+
: requestedTools;
|
|
881
|
+
session.setActiveToolsByName(activeTools);
|
|
882
|
+
const unsub = session.subscribe((event) => {
|
|
883
|
+
const mapped = mapSdkEvent(sessionId, event);
|
|
884
|
+
if (mapped)
|
|
885
|
+
onEvent(mapped);
|
|
886
|
+
});
|
|
887
|
+
const onAbort = () => {
|
|
888
|
+
// M1 T08: Stop must abort only the current turn, NOT dispose the
|
|
889
|
+
// session. SDK AgentSession.abort() aborts the current operation and
|
|
890
|
+
// waits for the agent to become idle, keeping the session reusable for
|
|
891
|
+
// the next turn. Only fall back to dispose if the session has no abort()
|
|
892
|
+
// (e.g. a minimal stub), since a stuck turn should not leak forever.
|
|
893
|
+
try {
|
|
894
|
+
if (typeof session.abort === "function") {
|
|
895
|
+
void session.abort();
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
session.dispose();
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
catch {
|
|
902
|
+
// ignore
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
options?.signal?.addEventListener("abort", onAbort);
|
|
906
|
+
try {
|
|
907
|
+
await session.prompt(text, options?.images?.length ? { images: options.images } : undefined);
|
|
908
|
+
// Wait until the session reports idle / not streaming. The SDK prompt()
|
|
909
|
+
// resolves when the agent turn completes, but post-turn continuation
|
|
910
|
+
// (auto-compaction / follow-up) may still be in flight. We poll isIdle.
|
|
911
|
+
// While polling we emit periodic heartbeat events so the SSE stream
|
|
912
|
+
// keeps proxies/browsers from timing out (no other data is flowing).
|
|
913
|
+
await waitForIdle(session, {
|
|
914
|
+
signal: options?.signal,
|
|
915
|
+
onHeartbeat: () => onEvent({ type: "heartbeat", sessionId }),
|
|
916
|
+
});
|
|
917
|
+
onEvent({ type: "agent_settled", sessionId });
|
|
918
|
+
}
|
|
919
|
+
catch (error) {
|
|
920
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
921
|
+
onEvent({ type: "error", sessionId, message });
|
|
922
|
+
throw error;
|
|
923
|
+
}
|
|
924
|
+
finally {
|
|
925
|
+
options?.signal?.removeEventListener("abort", onAbort);
|
|
926
|
+
unsub();
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
async compact(sessionId, customInstructions) {
|
|
930
|
+
const session = this.sessions.get(sessionId);
|
|
931
|
+
if (!session)
|
|
932
|
+
throw new Error(`chat session not found: ${sessionId}`);
|
|
933
|
+
if (session.isStreaming === true || session.isIdle === false)
|
|
934
|
+
throw new Error("CHAT_TURN_ACTIVE");
|
|
935
|
+
if (typeof session.compact !== "function")
|
|
936
|
+
throw new Error("COMPACTION_UNSUPPORTED");
|
|
937
|
+
const result = await session.compact(customInstructions);
|
|
938
|
+
return projectCompactSnapshot(result ?? {});
|
|
939
|
+
}
|
|
940
|
+
dispose(sessionId) {
|
|
941
|
+
const session = this.sessions.get(sessionId);
|
|
942
|
+
if (session) {
|
|
943
|
+
try {
|
|
944
|
+
session.dispose();
|
|
945
|
+
}
|
|
946
|
+
catch {
|
|
947
|
+
// ignore
|
|
948
|
+
}
|
|
949
|
+
this.sessions.delete(sessionId);
|
|
950
|
+
this.sessionManagers.delete(sessionId);
|
|
951
|
+
this.mainlineLeaves.delete(sessionId);
|
|
952
|
+
this.modelRuntimes.delete(sessionId);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
disposeAll() {
|
|
956
|
+
for (const id of [...this.sessions.keys()])
|
|
957
|
+
this.dispose(id);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
function mapSdkEvent(sessionId, event) {
|
|
961
|
+
switch (event.type) {
|
|
962
|
+
case "agent_start":
|
|
963
|
+
return { type: "agent_start", sessionId };
|
|
964
|
+
case "agent_end":
|
|
965
|
+
return {
|
|
966
|
+
type: "agent_end",
|
|
967
|
+
sessionId,
|
|
968
|
+
willRetry: Boolean(event.willRetry),
|
|
969
|
+
};
|
|
970
|
+
case "message_update":
|
|
971
|
+
return {
|
|
972
|
+
type: "message_update",
|
|
973
|
+
sessionId,
|
|
974
|
+
text: extractAssistantText(event.message),
|
|
975
|
+
};
|
|
976
|
+
case "message_end":
|
|
977
|
+
return { type: "message_end", sessionId, text: extractAssistantText(event.message) };
|
|
978
|
+
case "turn_end": {
|
|
979
|
+
const usage = extractUsageSample(event);
|
|
980
|
+
return usage ? { type: "usage", sessionId, usage } : undefined;
|
|
981
|
+
}
|
|
982
|
+
case "tool_execution_start":
|
|
983
|
+
return {
|
|
984
|
+
type: "tool_call",
|
|
985
|
+
sessionId,
|
|
986
|
+
toolCallId: event.toolCallId,
|
|
987
|
+
toolName: event.toolName,
|
|
988
|
+
args: event.args,
|
|
989
|
+
};
|
|
990
|
+
case "tool_execution_end":
|
|
991
|
+
return {
|
|
992
|
+
type: "tool_result",
|
|
993
|
+
sessionId,
|
|
994
|
+
toolCallId: event.toolCallId,
|
|
995
|
+
toolName: event.toolName,
|
|
996
|
+
result: event.result,
|
|
997
|
+
isError: event.isError,
|
|
998
|
+
};
|
|
999
|
+
case "agent_settled":
|
|
1000
|
+
return { type: "agent_settled", sessionId };
|
|
1001
|
+
default:
|
|
1002
|
+
return undefined;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
async function waitForIdle(session, options) {
|
|
1006
|
+
// Default 45s — shorter than the previous 120s so a stuck SDK session does
|
|
1007
|
+
// not pin an HTTP connection for two full minutes. The SDK agent turn
|
|
1008
|
+
// normally settles in seconds; 45s covers auto-compaction/follow-up tails
|
|
1009
|
+
// while staying under typical proxy idle timeouts (60s).
|
|
1010
|
+
const timeoutMs = options?.timeoutMs ?? 45_000;
|
|
1011
|
+
const heartbeatIntervalMs = options?.heartbeatIntervalMs ?? 10_000;
|
|
1012
|
+
const start = Date.now();
|
|
1013
|
+
let lastHeartbeat = start;
|
|
1014
|
+
// eslint-disable-next-line no-constant-condition
|
|
1015
|
+
while (true) {
|
|
1016
|
+
if (options?.signal?.aborted)
|
|
1017
|
+
return;
|
|
1018
|
+
if (session.isIdle === true)
|
|
1019
|
+
return;
|
|
1020
|
+
if (session.isStreaming === false)
|
|
1021
|
+
return;
|
|
1022
|
+
const now = Date.now();
|
|
1023
|
+
if (now - start > timeoutMs)
|
|
1024
|
+
return;
|
|
1025
|
+
if (options?.onHeartbeat && now - lastHeartbeat >= heartbeatIntervalMs) {
|
|
1026
|
+
lastHeartbeat = now;
|
|
1027
|
+
try {
|
|
1028
|
+
options.onHeartbeat();
|
|
1029
|
+
}
|
|
1030
|
+
catch {
|
|
1031
|
+
// Heartbeat is best-effort; never let it break the wait loop.
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
async function safeGetAgentDir(bindings) {
|
|
1038
|
+
try {
|
|
1039
|
+
return await bindings.getAgentDir();
|
|
1040
|
+
}
|
|
1041
|
+
catch {
|
|
1042
|
+
// Fall back to a Console-app-data scoped agent dir so we never accidentally
|
|
1043
|
+
// touch the user's real ~/.pi/agent config if getAgentDir throws.
|
|
1044
|
+
return path.join(process.cwd(), ".harness", "console", "pi-agent-dir");
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Cached SDK module loader. The pi-coding-agent package is ESM-only, so we
|
|
1049
|
+
* must use dynamic import() (not require). Cached to avoid repeated imports.
|
|
1050
|
+
*/
|
|
1051
|
+
let cachedSdk;
|
|
1052
|
+
async function loadSdk() {
|
|
1053
|
+
if (!cachedSdk) {
|
|
1054
|
+
cachedSdk = import("@earendil-works/pi-coding-agent");
|
|
1055
|
+
}
|
|
1056
|
+
return cachedSdk;
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Default production bindings backed by the real Pi SDK (services mode).
|
|
1060
|
+
* Dynamically imported so the worker bundle does not hard-fail if the SDK is
|
|
1061
|
+
* absent at module load (discovered lazily by pi-readiness instead).
|
|
1062
|
+
*/
|
|
1063
|
+
export function createDefaultPiSdkBindings() {
|
|
1064
|
+
return {
|
|
1065
|
+
getAgentDir: async () => {
|
|
1066
|
+
const sdk = (await loadSdk());
|
|
1067
|
+
return sdk.getAgentDir();
|
|
1068
|
+
},
|
|
1069
|
+
createServices: async (opts) => {
|
|
1070
|
+
const sdk = (await loadSdk());
|
|
1071
|
+
// createAgentSessionServices returns the full AgentSessionServices object
|
|
1072
|
+
// directly (cwd/agentDir/modelRuntime/settingsManager/resourceLoader).
|
|
1073
|
+
// Wrap it so our type contract is satisfied.
|
|
1074
|
+
const result = await sdk.createAgentSessionServices({
|
|
1075
|
+
cwd: opts.cwd,
|
|
1076
|
+
agentDir: opts.agentDir,
|
|
1077
|
+
resourceLoaderOptions: opts.resourceLoaderOptions,
|
|
1078
|
+
});
|
|
1079
|
+
return { services: result };
|
|
1080
|
+
},
|
|
1081
|
+
createSessionFromServices: async (opts) => {
|
|
1082
|
+
const sdk = (await loadSdk());
|
|
1083
|
+
return sdk.createAgentSessionFromServices({
|
|
1084
|
+
services: opts.services,
|
|
1085
|
+
sessionManager: opts.sessionManager,
|
|
1086
|
+
...(opts.tools ? { tools: opts.tools } : {}),
|
|
1087
|
+
...(opts.noTools ? { noTools: opts.noTools } : {}),
|
|
1088
|
+
...(opts.excludeTools ? { excludeTools: opts.excludeTools } : {}),
|
|
1089
|
+
...(opts.customTools ? { customTools: opts.customTools } : {}),
|
|
1090
|
+
...(opts.model ? { model: opts.model } : {}),
|
|
1091
|
+
});
|
|
1092
|
+
},
|
|
1093
|
+
createSessionManager: async (opts) => {
|
|
1094
|
+
const sdk = (await loadSdk());
|
|
1095
|
+
return sdk.SessionManager.create(opts.cwd, opts.sessionDir, {
|
|
1096
|
+
id: opts.sessionId,
|
|
1097
|
+
});
|
|
1098
|
+
},
|
|
1099
|
+
forkSessionManager: async (opts) => {
|
|
1100
|
+
const sdk = (await loadSdk());
|
|
1101
|
+
return sdk.SessionManager.forkFrom(opts.sourcePath, opts.targetCwd, opts.targetSessionDir, { id: opts.targetSessionId });
|
|
1102
|
+
},
|
|
1103
|
+
openSessionManager: async (opts) => {
|
|
1104
|
+
const sdk = (await loadSdk());
|
|
1105
|
+
try {
|
|
1106
|
+
return sdk.SessionManager.open(opts.sessionFile);
|
|
1107
|
+
}
|
|
1108
|
+
catch {
|
|
1109
|
+
// Missing/corrupt session file — caller fails closed rather than
|
|
1110
|
+
// silently creating a fresh session and losing prior context.
|
|
1111
|
+
return undefined;
|
|
1112
|
+
}
|
|
1113
|
+
},
|
|
1114
|
+
resolveModel: (opts) => {
|
|
1115
|
+
const rt = opts.modelRuntime;
|
|
1116
|
+
if (typeof rt?.getModel !== "function")
|
|
1117
|
+
return undefined;
|
|
1118
|
+
try {
|
|
1119
|
+
return rt.getModel(opts.provider, opts.modelId);
|
|
1120
|
+
}
|
|
1121
|
+
catch {
|
|
1122
|
+
return undefined;
|
|
1123
|
+
}
|
|
1124
|
+
},
|
|
1125
|
+
listAvailableModels: (opts) => {
|
|
1126
|
+
const rt = opts.modelRuntime;
|
|
1127
|
+
const available = rt?.snapshot?.available ?? [];
|
|
1128
|
+
const configured = rt?.snapshot?.configuredProviders;
|
|
1129
|
+
const isConfigured = (provider) => configured instanceof Set ? configured.has(provider) : false;
|
|
1130
|
+
return available.map((m) => ({
|
|
1131
|
+
id: m.id,
|
|
1132
|
+
name: m.name,
|
|
1133
|
+
provider: m.provider,
|
|
1134
|
+
hasCredentials: isConfigured(m.provider),
|
|
1135
|
+
}));
|
|
1136
|
+
},
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Re-export the tool gate for the dispatcher layer so the per-tool-call gate
|
|
1141
|
+
* (Gate 3) lives in exactly one place.
|
|
1142
|
+
*/
|
|
1143
|
+
export { authorizeOperatorChatTool, assertNoWriteToolInList, OPERATOR_CHAT_ALLOWED_TOOLS, };
|