@tea-agent/loop-agent 0.21.0 → 0.23.1
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 +114 -0
- package/README.md +7 -4
- package/bin/agent-worker.js +0 -0
- package/dist/adapters/loop-agent.js +52 -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 +198 -86
- package/dist/commands/stats.js +40 -11
- package/dist/executors/dag-pi-executor.js +2 -0
- package/dist/executors/shell-executor.js +162 -19
- package/dist/shared/openspec-spec.js +49 -0
- package/dist/shared/operator/capabilities.js +11 -1
- package/dist/worker/console/app-data.js +4 -0
- package/dist/worker/console/chat/instruction-skills.js +217 -0
- package/dist/worker/console/chat/model-resolver.js +106 -0
- package/dist/worker/console/chat/pi-runtime.js +605 -0
- package/dist/worker/console/chat/resource-loader.js +66 -0
- package/dist/worker/console/chat/routes.js +357 -0
- package/dist/worker/console/chat/session-store.js +238 -0
- package/dist/worker/console/chat/tool-adapter.js +238 -0
- package/dist/worker/console/chat/tools.js +171 -0
- package/dist/worker/console/server.js +55 -0
- package/dist/worker/console/static/assets/index-DVl7Jxt5.js +25 -0
- package/dist/worker/console/static/assets/index-lVcIr9Ju.css +1 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/feature/profile-schema.js +1 -1
- package/dist/worker/observability/read-model.js +21 -1
- package/dist/worker/observe/spec-evidence.js +12 -15
- package/dist/worker/observe/static/dag-helpers.js +22 -0
- package/dist/worker/observe/static/views/dag.js +5 -0
- package/dist/workflows/dag/backend-test-markdown-workflow.js +37 -0
- package/dist/workflows/dag/frontend-implementation-contract.js +141 -32
- package/dist/workflows/dag/frontend-lint-baseline.js +471 -0
- package/dist/workflows/dag/frontend-prewrite-gate.js +79 -16
- package/dist/workflows/dag/frontend-project-capability.js +11 -8
- package/dist/workflows/dag/frontend-repair.js +6 -4
- package/dist/workflows/dag/frontend-review-context.js +67 -0
- package/dist/workflows/dag/frontend-test-case-quality.js +105 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +71 -66
- package/dist/workflows/dag/frontend-verification-trace.js +31 -1
- package/dist/workflows/dag/frontend-worktree-diff.js +81 -6
- package/dist/workflows/dag/init-hybrid.js +412 -84
- 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 +74 -1
- package/dist/workflows/dag/validate.js +55 -0
- package/docs/README.md +73 -156
- package/docs/architecture/README.md +3 -2
- package/docs/architecture/dag-execution.md +2 -2
- package/docs/architecture/evolution.md +14 -12
- 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/agent-dag.schema.json +15 -5
- package/docs/templates/backend-test-dag.json +1 -1
- 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/frontend-implementation-contract.schema.json +4 -3
- package/docs/templates/frontend-test-case-checklist.md +6 -2
- package/docs/templates/frontend-test-dag.json +2 -2
- package/docs/templates/hybrid-dag.json +1 -1
- package/docs/templates/progress-log.md +9 -2
- package/harness.json +5 -5
- package/package.json +5 -5
- package/scripts/kb-bootstrap-init-skeleton.sh +2 -2
- package/skills/agent-worker/SKILL.md +1 -1
- package/skills/frontend-design-review/SKILL.md +12 -10
- package/skills/frontend-design-review/references/review-checklist.md +4 -4
- package/skills/frontend-implementation/SKILL.md +2 -2
- package/skills/frontend-implementation/references/code-standards.md +4 -3
- package/skills/frontend-implementation/references/design-spec.md +19 -14
- package/skills/frontend-implementation/references/node-contracts.md +2 -2
- package/skills/frontend-review/SKILL.md +15 -28
- package/skills/frontend-review/references/review-findings.md +16 -18
- package/skills/frontend-verification/SKILL.md +16 -13
- package/skills/frontend-verification/references/verification-checklist.md +18 -30
- 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,605 @@
|
|
|
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 path from "node:path";
|
|
28
|
+
import { OPERATOR_CHAT_ALLOWED_TOOLS, authorizeOperatorChatTool, assertNoWriteToolInList, } from "./tools.js";
|
|
29
|
+
import { createOperatorChatResourceLoader, } from "./resource-loader.js";
|
|
30
|
+
import { loadOperatorChatInstructionSkills, composeInstructionSkillsPrompt, } from "./instruction-skills.js";
|
|
31
|
+
import { buildOperatorChatToolSchemas } from "./tool-adapter.js";
|
|
32
|
+
import { resolveDefaultChatModel, } from "./model-resolver.js";
|
|
33
|
+
/**
|
|
34
|
+
* Base system prompt fragment every General Operator Chat session receives.
|
|
35
|
+
*
|
|
36
|
+
* Establishes the operator-vs-implementer role boundary (ADR 0005 D2) and the
|
|
37
|
+
* current tool surface contract (2026-07-25 widening): the Chat exposes the
|
|
38
|
+
* FULL operator action surface (all registry actions, including high-risk
|
|
39
|
+
* mutations like contractApply / runDag / dagRerun) PLUS the built-in
|
|
40
|
+
* read/explore tools (bash / read / grep / find / ls). The ONLY thing still
|
|
41
|
+
* forbidden is direct file-WRITING via coding tools (edit / write /
|
|
42
|
+
* apply_patch / full-tools / shell / coding-chat) — those are excluded from
|
|
43
|
+
* the SDK registry entirely and can never be activated.
|
|
44
|
+
*
|
|
45
|
+
* Instruction skills are appended AFTER this as read-only context (plan D1).
|
|
46
|
+
*/
|
|
47
|
+
const OPERATOR_CHAT_SYSTEM_PROMPT_BASE = [
|
|
48
|
+
"You are the General Operator Chat for loop-agent / agent-worker.",
|
|
49
|
+
"You are an OPERATOR: you orchestrate and inspect via the registered operator_* tools. You are NOT an implementer.",
|
|
50
|
+
"Tool surface: you have the built-in read/explore tools (bash, read, grep, find, ls) to probe the repo, AND the full set of operator_* tools (all registered operator actions, including high-risk mutations such as contractApply / runDag / dagRerun / standaloneTaskRerun / workerTaskRetry / importPrd / confirmDagConfirmation / dagRunTask).",
|
|
51
|
+
"You do NOT have edit / write / apply_patch / full-tools / shell / coding-chat — file-WRITING is still gated behind the DAG path. All canonical writes still go through DAG implement-pi / repair-pi, never via this chat. The residual write-via-bash-redirect risk (`echo >` / `tee`) is an accepted documented residual.",
|
|
52
|
+
"High-risk mutations are now callable as tools, but each still requires its prepared receipt (e.g. contractApply needs a fresh complete assessment; runDag needs a confirmationId from prepareDagConfirmation; dagRerun needs a planHash). When a high-risk action fails on missing prepared state, use read-only tools (status, doctor, dagReport, inspect, contractShow) to diagnose, then tell the user to prepare the receipt via the human-confirmation page if needed.",
|
|
53
|
+
"Prefer read-only tools (status, doctor, dagReport, inspect, contractShow) to diagnose before acting.",
|
|
54
|
+
].join("\n");
|
|
55
|
+
/**
|
|
56
|
+
* Built-in SDK read/explore tool names registered for every Chat session.
|
|
57
|
+
*
|
|
58
|
+
* These MUST be passed as `tools:` to createSessionFromServices (Gate 1) so the
|
|
59
|
+
* SDK actually registers grep/find/ls — its default builtin set is only
|
|
60
|
+
* read/bash/edit/write. Excluding edit/write via excludeTools then leaves
|
|
61
|
+
* exactly bash/read/grep/find/ls as the builtin surface.
|
|
62
|
+
*/
|
|
63
|
+
export const OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS = Object.freeze([
|
|
64
|
+
"bash",
|
|
65
|
+
"read",
|
|
66
|
+
"grep",
|
|
67
|
+
"find",
|
|
68
|
+
"ls",
|
|
69
|
+
]);
|
|
70
|
+
/**
|
|
71
|
+
* Canonical active tool-set handed to setActiveToolsByName at every gate:
|
|
72
|
+
* every operator action (dynamic, from the registry) PLUS the built-in
|
|
73
|
+
* explore tools. File-WRITING coding tools (edit/write/...) are never here —
|
|
74
|
+
* they are excluded at the registry level and thus cannot be activated.
|
|
75
|
+
*/
|
|
76
|
+
export function OPERATOR_CHAT_ACTIVE_TOOL_NAMES() {
|
|
77
|
+
return [...OPERATOR_CHAT_ALLOWED_TOOLS, ...OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS];
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Build Pi custom ToolDefinition objects for every whitelisted operator action.
|
|
81
|
+
* Each tool's execute() routes into the operator action dispatcher (Gate 3).
|
|
82
|
+
* Requires the `typebox` + `defineTool` packages (both bundled with the SDK).
|
|
83
|
+
*/
|
|
84
|
+
async function buildCustomOperatorTools(options) {
|
|
85
|
+
// typebox is a transitive dep of @earendil-works/pi-coding-agent; Node's
|
|
86
|
+
// resolver hoists to the SDK's nested copy. defineTool is re-exported by SDK.
|
|
87
|
+
const [{ Type }, { defineTool }] = await Promise.all([
|
|
88
|
+
import("typebox"),
|
|
89
|
+
import("@earendil-works/pi-coding-agent"),
|
|
90
|
+
]);
|
|
91
|
+
const schemas = buildOperatorChatToolSchemas();
|
|
92
|
+
const ctx = options.actionContext;
|
|
93
|
+
return schemas.map((schema) => {
|
|
94
|
+
// TypeBox schema is built dynamically; cast through unknown to the builder
|
|
95
|
+
// since Static<TParams> is structural and cannot be statically inferred here.
|
|
96
|
+
const parameters = Type.Object(schema.inputParams.reduce((acc, param) => {
|
|
97
|
+
let t;
|
|
98
|
+
switch (param.type) {
|
|
99
|
+
case "string":
|
|
100
|
+
t = Type.String();
|
|
101
|
+
break;
|
|
102
|
+
case "boolean":
|
|
103
|
+
t = Type.Boolean();
|
|
104
|
+
break;
|
|
105
|
+
case "number":
|
|
106
|
+
t = Type.Number();
|
|
107
|
+
break;
|
|
108
|
+
case "array":
|
|
109
|
+
t = Type.Array(Type.String());
|
|
110
|
+
break;
|
|
111
|
+
default:
|
|
112
|
+
t = Type.Object({}, { additionalProperties: true });
|
|
113
|
+
}
|
|
114
|
+
acc[param.name] = param.required
|
|
115
|
+
? t
|
|
116
|
+
: Type.Optional(t);
|
|
117
|
+
return acc;
|
|
118
|
+
}, {}), { additionalProperties: false });
|
|
119
|
+
return defineTool({
|
|
120
|
+
name: schema.toolId,
|
|
121
|
+
label: schema.toolId,
|
|
122
|
+
description: `${schema.description} (kind=${schema.kind}; cli: ${schema.cli})`,
|
|
123
|
+
promptSnippet: `${schema.toolId}: ${schema.description}`,
|
|
124
|
+
parameters: parameters,
|
|
125
|
+
async execute(toolCallId, params) {
|
|
126
|
+
const decision = authorizeOperatorChatTool(schema.toolId);
|
|
127
|
+
if (!decision.ok) {
|
|
128
|
+
return {
|
|
129
|
+
content: [
|
|
130
|
+
{ type: "text", text: JSON.stringify({ error: decision.message, code: decision.code }) },
|
|
131
|
+
],
|
|
132
|
+
details: { error: decision.message, code: decision.code },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
// Defer require to avoid circular import at module load.
|
|
136
|
+
const { dispatchChatToolCall } = await import("./session-store.js");
|
|
137
|
+
const result = await dispatchChatToolCall(ctx, decision.toolId, params, toolCallId);
|
|
138
|
+
const payload = result.ok
|
|
139
|
+
? result.result
|
|
140
|
+
: { error: result.message, code: result.errorCode };
|
|
141
|
+
return {
|
|
142
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
143
|
+
details: payload,
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Extract the latest assistant text fragment from an SDK message object.
|
|
151
|
+
* Tolerant of multiple message shapes the SDK emits.
|
|
152
|
+
*/
|
|
153
|
+
export function extractAssistantText(message) {
|
|
154
|
+
if (!message || typeof message !== "object")
|
|
155
|
+
return "";
|
|
156
|
+
const m = message;
|
|
157
|
+
if (typeof m.text === "string")
|
|
158
|
+
return m.text;
|
|
159
|
+
const contents = Array.isArray(m.content) ? m.content : [];
|
|
160
|
+
const parts = Array.isArray(m.parts) ? m.parts : [];
|
|
161
|
+
const segments = [];
|
|
162
|
+
for (const c of [...contents, ...parts]) {
|
|
163
|
+
if (!c || typeof c !== "object")
|
|
164
|
+
continue;
|
|
165
|
+
const seg = c;
|
|
166
|
+
if (typeof seg.text === "string")
|
|
167
|
+
segments.push(seg.text);
|
|
168
|
+
}
|
|
169
|
+
return segments.join("");
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Console Pi runtime — holds Chat sessions and drives prompt() with three-gate
|
|
173
|
+
* tool enforcement. Session-per-sessionId; cross-request reuse via open().
|
|
174
|
+
*/
|
|
175
|
+
export class ConsolePiRuntime {
|
|
176
|
+
options;
|
|
177
|
+
loader;
|
|
178
|
+
sessions = new Map();
|
|
179
|
+
bindings;
|
|
180
|
+
constructor(options) {
|
|
181
|
+
this.options = options;
|
|
182
|
+
this.loader = createOperatorChatResourceLoader();
|
|
183
|
+
this.bindings =
|
|
184
|
+
options.bindings ?? createDefaultPiSdkBindings();
|
|
185
|
+
}
|
|
186
|
+
get sessionDir() {
|
|
187
|
+
return this.options.sessionDir;
|
|
188
|
+
}
|
|
189
|
+
/** Operator action context used by custom-tool dispatchers (Gate 3). */
|
|
190
|
+
get actionContext() {
|
|
191
|
+
return this.options.actionContext;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Resolve the Chat default model descriptor from harness.json
|
|
195
|
+
* executors.pi.MED + the SDK available-model list. Returns undefined when
|
|
196
|
+
* harness is unset or no provider surfaces the model. No hardcoded fallback.
|
|
197
|
+
*/
|
|
198
|
+
async resolveDefaultModel() {
|
|
199
|
+
let available;
|
|
200
|
+
try {
|
|
201
|
+
// Build a throwaway services just to read modelRuntime.snapshot. This is
|
|
202
|
+
// cheap (SDK caches); tests inject bindings that stub listAvailableModels.
|
|
203
|
+
const { services } = await this.bindings.createServices({
|
|
204
|
+
cwd: this.options.cwd,
|
|
205
|
+
agentDir: await safeGetAgentDir(this.bindings),
|
|
206
|
+
});
|
|
207
|
+
available = this.bindings.listAvailableModels({
|
|
208
|
+
modelRuntime: services.modelRuntime,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
const descriptor = await resolveDefaultChatModel(this.options.cwd, available);
|
|
215
|
+
if (!descriptor)
|
|
216
|
+
return undefined;
|
|
217
|
+
return {
|
|
218
|
+
provider: descriptor.provider,
|
|
219
|
+
modelId: descriptor.modelId,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Load the operator-context instruction skills (plan D1) and compose the
|
|
224
|
+
* read-only methodology fragment for the Chat system prompt. Returns an
|
|
225
|
+
* empty string when no skills load (e.g. skills dir missing) so the
|
|
226
|
+
* session still boots — skills are context, not a hard dependency.
|
|
227
|
+
*/
|
|
228
|
+
async composeSystemPromptSkills() {
|
|
229
|
+
try {
|
|
230
|
+
const result = await loadOperatorChatInstructionSkills(this.options.skillsDir);
|
|
231
|
+
return composeInstructionSkillsPrompt(result.loaded);
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
process.stderr.write(`[console] chat instruction-skills load failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
235
|
+
return "";
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** Create a new isolated Chat session pinned to the operator-chat tool set. */
|
|
239
|
+
async createSession(init) {
|
|
240
|
+
const sessionId = `operator-chat-${randomBytes(12).toString("hex")}`;
|
|
241
|
+
const agentDir = await safeGetAgentDir(this.bindings);
|
|
242
|
+
// Resolve default model from harness.json executors.pi.MED (no hardcode).
|
|
243
|
+
// Caller may pass an explicit model; otherwise we read the repo harness and
|
|
244
|
+
// search the SDK available-model list for a credentialled provider.
|
|
245
|
+
let model = init?.model;
|
|
246
|
+
if (!model) {
|
|
247
|
+
const resolved = await this.resolveDefaultModel();
|
|
248
|
+
if (resolved)
|
|
249
|
+
model = resolved;
|
|
250
|
+
}
|
|
251
|
+
// Final fallback only when nothing resolved: let the SDK pick its default
|
|
252
|
+
// (we do NOT hardcode a provider here).
|
|
253
|
+
// Load operator-context instruction skills (plan D1) and compose the
|
|
254
|
+
// read-only methodology fragment injected into the Chat system prompt.
|
|
255
|
+
// Skills are NEVER registered as executable tools (D1 hard rule).
|
|
256
|
+
const skillFragment = await this.composeSystemPromptSkills();
|
|
257
|
+
const appendSystemPrompt = [
|
|
258
|
+
OPERATOR_CHAT_SYSTEM_PROMPT_BASE,
|
|
259
|
+
...(skillFragment ? [skillFragment] : []),
|
|
260
|
+
...(init?.systemPromptSuffix ? [init.systemPromptSuffix] : []),
|
|
261
|
+
];
|
|
262
|
+
const { services } = await this.bindings.createServices({
|
|
263
|
+
cwd: this.options.cwd,
|
|
264
|
+
agentDir,
|
|
265
|
+
resourceLoaderOptions: {
|
|
266
|
+
// Closed surface: do not load user/project extensions, skills,
|
|
267
|
+
// AGENTS.md, context files. The Chat tool surface is fixed by
|
|
268
|
+
// the whitelist, not by filesystem discovery.
|
|
269
|
+
noContextFiles: true,
|
|
270
|
+
noSkills: true,
|
|
271
|
+
noExtensions: true,
|
|
272
|
+
// Inject the audited instruction skills as READ-ONLY context
|
|
273
|
+
// (plan D1). appendSystemPrompt is the SDK's documented entry
|
|
274
|
+
// point for adding system-prompt text without enabling tools.
|
|
275
|
+
appendSystemPrompt,
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
const modelRuntime = services.modelRuntime;
|
|
279
|
+
// Resolve the concrete SDK model only when we have a descriptor. When no
|
|
280
|
+
// model is resolved (harness unset + no snapshot match), we omit `model`
|
|
281
|
+
// and let the SDK session use its own default (no hardcoded fallback).
|
|
282
|
+
const resolvedModel = model
|
|
283
|
+
? (this.bindings.resolveModel({
|
|
284
|
+
modelRuntime,
|
|
285
|
+
provider: model.provider,
|
|
286
|
+
modelId: model.modelId,
|
|
287
|
+
}) ?? undefined)
|
|
288
|
+
: undefined;
|
|
289
|
+
const sessionManager = await this.bindings.createSessionManager({
|
|
290
|
+
cwd: this.options.cwd,
|
|
291
|
+
sessionDir: this.options.sessionDir,
|
|
292
|
+
sessionId,
|
|
293
|
+
});
|
|
294
|
+
// Gate 1: pin the tool set at session create. assertNoWriteTool guards
|
|
295
|
+
// that no file-WRITING coding tool slipped into the allowed list (bash
|
|
296
|
+
// is intentionally allowed since the 2026-07-25 widening).
|
|
297
|
+
assertNoWriteToolInList(OPERATOR_CHAT_ALLOWED_TOOLS);
|
|
298
|
+
// Build custom Pi tools that route whitelisted operator actions into
|
|
299
|
+
// the dispatcher (Gate 3 lives inside each tool's execute()).
|
|
300
|
+
let customTools;
|
|
301
|
+
if (this.options.actionContext) {
|
|
302
|
+
try {
|
|
303
|
+
customTools = await buildCustomOperatorTools({
|
|
304
|
+
actionContext: this.options.actionContext,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
// If custom-tool build fails (e.g. typebox unavailable), fall back to
|
|
309
|
+
// a tool-less session — Chat still works as text-only, and the
|
|
310
|
+
// failure is surfaced via doctor / readiness diagnostics.
|
|
311
|
+
process.stderr.write(`[console] chat custom-tools build failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// Tool surface (2026-07-25 widening):
|
|
315
|
+
// - ALL operator actions are registered as customTools (full runtime
|
|
316
|
+
// capability surface — derived dynamically from the registry).
|
|
317
|
+
// - Built-in read/explore tools (bash/read/grep/find/ls) are registered
|
|
318
|
+
// by passing `tools: OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS` (Gate 1).
|
|
319
|
+
// This is REQUIRED: the SDK's default builtin set is only
|
|
320
|
+
// `read, bash, edit, write` (grep/find/ls are NOT default builtins —
|
|
321
|
+
// see SDK CreateAgentSessionOptions.tools docs). Without `tools`, the
|
|
322
|
+
// later setActiveToolsByName(["grep","find","ls"]) would silently
|
|
323
|
+
// no-op because those tools would not exist in the registry.
|
|
324
|
+
// - We then exclude edit/write so the only file-WRITING path is gone.
|
|
325
|
+
// - bash is bare (no command filter); the residual write-via-redirect
|
|
326
|
+
// risk is accepted (exec-plan Q9).
|
|
327
|
+
const { session } = await this.bindings.createSessionFromServices({
|
|
328
|
+
services,
|
|
329
|
+
sessionManager,
|
|
330
|
+
// Explicitly register the explore-tool builtin set (Gate 1). This is
|
|
331
|
+
// what makes grep/find/ls actually available — the SDK default builtin
|
|
332
|
+
// set is only read/bash/edit/write. Combined with excludeTools below,
|
|
333
|
+
// the effective registry = explore tools + customTools (operator actions).
|
|
334
|
+
tools: [...OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS],
|
|
335
|
+
excludeTools: [
|
|
336
|
+
"edit",
|
|
337
|
+
"write",
|
|
338
|
+
"apply_patch",
|
|
339
|
+
"apply-patch",
|
|
340
|
+
"full-tools",
|
|
341
|
+
"full_tools",
|
|
342
|
+
"coding-chat",
|
|
343
|
+
"coding_chat",
|
|
344
|
+
"shell",
|
|
345
|
+
],
|
|
346
|
+
...(customTools && customTools.length > 0
|
|
347
|
+
? { customTools }
|
|
348
|
+
: {}),
|
|
349
|
+
...(resolvedModel ? { model: resolvedModel } : {}),
|
|
350
|
+
});
|
|
351
|
+
// Gate 2: re-pin active tools (defensive — some SDK paths may seed
|
|
352
|
+
// defaults). Active set = all operator actions + built-in explore tools.
|
|
353
|
+
// File-writing tools are excluded above and thus cannot appear here.
|
|
354
|
+
session.setActiveToolsByName(OPERATOR_CHAT_ACTIVE_TOOL_NAMES());
|
|
355
|
+
this.sessions.set(sessionId, session);
|
|
356
|
+
return {
|
|
357
|
+
sessionId,
|
|
358
|
+
sessionFile: session.sessionFile,
|
|
359
|
+
createdAt: new Date().toISOString(),
|
|
360
|
+
model,
|
|
361
|
+
activeTools: session.getActiveToolNames(),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
/** Re-open a previously created session (cross-request). */
|
|
365
|
+
open(sessionId) {
|
|
366
|
+
return this.sessions.get(sessionId);
|
|
367
|
+
}
|
|
368
|
+
hasSession(sessionId) {
|
|
369
|
+
return this.sessions.has(sessionId);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Send a prompt and stream events. Gate 3: each tool invocation is
|
|
373
|
+
* re-authorized by authorizeOperatorChatTool before the dispatcher runs it
|
|
374
|
+
* (the dispatcher is wired by the HTTP layer, see chat-session.ts).
|
|
375
|
+
*/
|
|
376
|
+
async prompt(sessionId, text, onEvent, options) {
|
|
377
|
+
const session = this.sessions.get(sessionId);
|
|
378
|
+
if (!session) {
|
|
379
|
+
throw new Error(`chat session not found: ${sessionId}`);
|
|
380
|
+
}
|
|
381
|
+
// Gate 2 (re-pin before each turn): defend against any mid-session drift.
|
|
382
|
+
session.setActiveToolsByName(OPERATOR_CHAT_ACTIVE_TOOL_NAMES());
|
|
383
|
+
const unsub = session.subscribe((event) => {
|
|
384
|
+
const mapped = mapSdkEvent(sessionId, event);
|
|
385
|
+
if (mapped)
|
|
386
|
+
onEvent(mapped);
|
|
387
|
+
});
|
|
388
|
+
const onAbort = () => {
|
|
389
|
+
try {
|
|
390
|
+
session.dispose();
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
// ignore
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
options?.signal?.addEventListener("abort", onAbort);
|
|
397
|
+
try {
|
|
398
|
+
await session.prompt(text);
|
|
399
|
+
// Wait until the session reports idle / not streaming. The SDK prompt()
|
|
400
|
+
// resolves when the agent turn completes, but post-turn continuation
|
|
401
|
+
// (auto-compaction / follow-up) may still be in flight. We poll isIdle.
|
|
402
|
+
// While polling we emit periodic heartbeat events so the SSE stream
|
|
403
|
+
// keeps proxies/browsers from timing out (no other data is flowing).
|
|
404
|
+
await waitForIdle(session, {
|
|
405
|
+
signal: options?.signal,
|
|
406
|
+
onHeartbeat: () => onEvent({ type: "heartbeat", sessionId }),
|
|
407
|
+
});
|
|
408
|
+
onEvent({ type: "agent_settled", sessionId });
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
412
|
+
onEvent({ type: "error", sessionId, message });
|
|
413
|
+
throw error;
|
|
414
|
+
}
|
|
415
|
+
finally {
|
|
416
|
+
options?.signal?.removeEventListener("abort", onAbort);
|
|
417
|
+
unsub();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
dispose(sessionId) {
|
|
421
|
+
const session = this.sessions.get(sessionId);
|
|
422
|
+
if (session) {
|
|
423
|
+
try {
|
|
424
|
+
session.dispose();
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
// ignore
|
|
428
|
+
}
|
|
429
|
+
this.sessions.delete(sessionId);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
disposeAll() {
|
|
433
|
+
for (const id of [...this.sessions.keys()])
|
|
434
|
+
this.dispose(id);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function mapSdkEvent(sessionId, event) {
|
|
438
|
+
switch (event.type) {
|
|
439
|
+
case "agent_start":
|
|
440
|
+
return { type: "agent_start", sessionId };
|
|
441
|
+
case "agent_end":
|
|
442
|
+
return {
|
|
443
|
+
type: "agent_end",
|
|
444
|
+
sessionId,
|
|
445
|
+
willRetry: Boolean(event.willRetry),
|
|
446
|
+
};
|
|
447
|
+
case "message_update":
|
|
448
|
+
return {
|
|
449
|
+
type: "message_update",
|
|
450
|
+
sessionId,
|
|
451
|
+
text: extractAssistantText(event.message),
|
|
452
|
+
};
|
|
453
|
+
case "message_end":
|
|
454
|
+
return {
|
|
455
|
+
type: "message_end",
|
|
456
|
+
sessionId,
|
|
457
|
+
text: extractAssistantText(event.message),
|
|
458
|
+
};
|
|
459
|
+
case "tool_execution_start":
|
|
460
|
+
return {
|
|
461
|
+
type: "tool_call",
|
|
462
|
+
sessionId,
|
|
463
|
+
toolCallId: event.toolCallId,
|
|
464
|
+
toolName: event.toolName,
|
|
465
|
+
args: event.args,
|
|
466
|
+
};
|
|
467
|
+
case "tool_execution_end":
|
|
468
|
+
return {
|
|
469
|
+
type: "tool_result",
|
|
470
|
+
sessionId,
|
|
471
|
+
toolCallId: event.toolCallId,
|
|
472
|
+
toolName: event.toolName,
|
|
473
|
+
result: event.result,
|
|
474
|
+
isError: event.isError,
|
|
475
|
+
};
|
|
476
|
+
case "agent_settled":
|
|
477
|
+
return { type: "agent_settled", sessionId };
|
|
478
|
+
default:
|
|
479
|
+
return undefined;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
async function waitForIdle(session, options) {
|
|
483
|
+
// Default 45s — shorter than the previous 120s so a stuck SDK session does
|
|
484
|
+
// not pin an HTTP connection for two full minutes. The SDK agent turn
|
|
485
|
+
// normally settles in seconds; 45s covers auto-compaction/follow-up tails
|
|
486
|
+
// while staying under typical proxy idle timeouts (60s).
|
|
487
|
+
const timeoutMs = options?.timeoutMs ?? 45_000;
|
|
488
|
+
const heartbeatIntervalMs = options?.heartbeatIntervalMs ?? 10_000;
|
|
489
|
+
const start = Date.now();
|
|
490
|
+
let lastHeartbeat = start;
|
|
491
|
+
// eslint-disable-next-line no-constant-condition
|
|
492
|
+
while (true) {
|
|
493
|
+
if (options?.signal?.aborted)
|
|
494
|
+
return;
|
|
495
|
+
if (session.isIdle === true)
|
|
496
|
+
return;
|
|
497
|
+
if (session.isStreaming === false)
|
|
498
|
+
return;
|
|
499
|
+
const now = Date.now();
|
|
500
|
+
if (now - start > timeoutMs)
|
|
501
|
+
return;
|
|
502
|
+
if (options?.onHeartbeat && now - lastHeartbeat >= heartbeatIntervalMs) {
|
|
503
|
+
lastHeartbeat = now;
|
|
504
|
+
try {
|
|
505
|
+
options.onHeartbeat();
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
// Heartbeat is best-effort; never let it break the wait loop.
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
async function safeGetAgentDir(bindings) {
|
|
515
|
+
try {
|
|
516
|
+
return await bindings.getAgentDir();
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
// Fall back to a Console-app-data scoped agent dir so we never accidentally
|
|
520
|
+
// touch the user's real ~/.pi/agent config if getAgentDir throws.
|
|
521
|
+
return path.join(process.cwd(), ".harness", "console", "pi-agent-dir");
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Cached SDK module loader. The pi-coding-agent package is ESM-only, so we
|
|
526
|
+
* must use dynamic import() (not require). Cached to avoid repeated imports.
|
|
527
|
+
*/
|
|
528
|
+
let cachedSdk;
|
|
529
|
+
async function loadSdk() {
|
|
530
|
+
if (!cachedSdk) {
|
|
531
|
+
cachedSdk = import("@earendil-works/pi-coding-agent");
|
|
532
|
+
}
|
|
533
|
+
return cachedSdk;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Default production bindings backed by the real Pi SDK (services mode).
|
|
537
|
+
* Dynamically imported so the worker bundle does not hard-fail if the SDK is
|
|
538
|
+
* absent at module load (discovered lazily by pi-readiness instead).
|
|
539
|
+
*/
|
|
540
|
+
export function createDefaultPiSdkBindings() {
|
|
541
|
+
return {
|
|
542
|
+
getAgentDir: async () => {
|
|
543
|
+
const sdk = (await loadSdk());
|
|
544
|
+
return sdk.getAgentDir();
|
|
545
|
+
},
|
|
546
|
+
createServices: async (opts) => {
|
|
547
|
+
const sdk = (await loadSdk());
|
|
548
|
+
// createAgentSessionServices returns the full AgentSessionServices object
|
|
549
|
+
// directly (cwd/agentDir/modelRuntime/settingsManager/resourceLoader).
|
|
550
|
+
// Wrap it so our type contract is satisfied.
|
|
551
|
+
const result = await sdk.createAgentSessionServices({
|
|
552
|
+
cwd: opts.cwd,
|
|
553
|
+
agentDir: opts.agentDir,
|
|
554
|
+
resourceLoaderOptions: opts.resourceLoaderOptions,
|
|
555
|
+
});
|
|
556
|
+
return { services: result };
|
|
557
|
+
},
|
|
558
|
+
createSessionFromServices: async (opts) => {
|
|
559
|
+
const sdk = (await loadSdk());
|
|
560
|
+
return sdk.createAgentSessionFromServices({
|
|
561
|
+
services: opts.services,
|
|
562
|
+
sessionManager: opts.sessionManager,
|
|
563
|
+
...(opts.tools ? { tools: opts.tools } : {}),
|
|
564
|
+
...(opts.noTools ? { noTools: opts.noTools } : {}),
|
|
565
|
+
...(opts.excludeTools ? { excludeTools: opts.excludeTools } : {}),
|
|
566
|
+
...(opts.customTools ? { customTools: opts.customTools } : {}),
|
|
567
|
+
...(opts.model ? { model: opts.model } : {}),
|
|
568
|
+
});
|
|
569
|
+
},
|
|
570
|
+
createSessionManager: async (opts) => {
|
|
571
|
+
const sdk = (await loadSdk());
|
|
572
|
+
return sdk.SessionManager.create(opts.cwd, opts.sessionDir, {
|
|
573
|
+
id: opts.sessionId,
|
|
574
|
+
});
|
|
575
|
+
},
|
|
576
|
+
resolveModel: (opts) => {
|
|
577
|
+
const rt = opts.modelRuntime;
|
|
578
|
+
if (typeof rt?.getModel !== "function")
|
|
579
|
+
return undefined;
|
|
580
|
+
try {
|
|
581
|
+
return rt.getModel(opts.provider, opts.modelId);
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
return undefined;
|
|
585
|
+
}
|
|
586
|
+
},
|
|
587
|
+
listAvailableModels: (opts) => {
|
|
588
|
+
const rt = opts.modelRuntime;
|
|
589
|
+
const available = rt?.snapshot?.available ?? [];
|
|
590
|
+
const configured = rt?.snapshot?.configuredProviders;
|
|
591
|
+
const isConfigured = (provider) => configured instanceof Set ? configured.has(provider) : false;
|
|
592
|
+
return available.map((m) => ({
|
|
593
|
+
id: m.id,
|
|
594
|
+
name: m.name,
|
|
595
|
+
provider: m.provider,
|
|
596
|
+
hasCredentials: isConfigured(m.provider),
|
|
597
|
+
}));
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Re-export the tool gate for the dispatcher layer so the per-tool-call gate
|
|
603
|
+
* (Gate 3) lives in exactly one place.
|
|
604
|
+
*/
|
|
605
|
+
export { authorizeOperatorChatTool, assertNoWriteToolInList, OPERATOR_CHAT_ALLOWED_TOOLS, };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator Chat — closed ResourceLoader contract (design §7.5 / V12 / plan W3).
|
|
3
|
+
*
|
|
4
|
+
* The Chat session does NOT discover user-level / project-level extensions or
|
|
5
|
+
* skills (its tool surface is fixed: the operator-action registry + the
|
|
6
|
+
* built-in explore tools). This module is the typed contract describing the
|
|
7
|
+
* DECLARED Chat tool surface — it mirrors what pi-runtime.ts actually wires
|
|
8
|
+
* into the SDK session, so that capabilities/doctor/red-team can report a
|
|
9
|
+
* single source of truth. It is deliberately framework-agnostic (does not
|
|
10
|
+
* import the Pi SDK at module top-level so it stays unit-testable).
|
|
11
|
+
*
|
|
12
|
+
* Current contract (2026-07-25 widening): bash/read/grep/find/ls are ALLOWED
|
|
13
|
+
* explore tools; the full operator action set is allowed; the ONLY denied
|
|
14
|
+
* tools are file-WRITING coding tools (edit/write/apply_patch/full-tools/
|
|
15
|
+
* shell/coding-chat). hasBash therefore reflects reality: true.
|
|
16
|
+
*
|
|
17
|
+
* Note: this adapter is NOT the SDK's own ResourceLoader (that is
|
|
18
|
+
* DefaultResourceLoader, created inside createAgentSessionServices with
|
|
19
|
+
* noContextFiles/noSkills/noExtensions). This adapter is the operator-chat
|
|
20
|
+
* declarative surface used for capabilities reporting and red-team checks.
|
|
21
|
+
* The three-gate authorization (authorizeOperatorChatTool) is the actual
|
|
22
|
+
* enforcement; this adapter describes what that gate admits.
|
|
23
|
+
*/
|
|
24
|
+
import { OPERATOR_CHAT_ALLOWED_TOOLS, OPERATOR_CHAT_DENIED_TOOLS, OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS, authorizeOperatorChatTool, filterOperatorChatTools, assertNoWriteToolInList, } from "./tools.js";
|
|
25
|
+
/** Built-in explore tool names that are ALLOWED in every Chat session. */
|
|
26
|
+
export const OPERATOR_CHAT_BUILTIN_EXPLORE_TOOL_IDS = Object.freeze([
|
|
27
|
+
"bash",
|
|
28
|
+
"read",
|
|
29
|
+
"grep",
|
|
30
|
+
"find",
|
|
31
|
+
"ls",
|
|
32
|
+
]);
|
|
33
|
+
export function createOperatorChatResourceLoader() {
|
|
34
|
+
let active = new Set();
|
|
35
|
+
return {
|
|
36
|
+
mode: "operator-chat-closed",
|
|
37
|
+
allowedToolIds: OPERATOR_CHAT_ALLOWED_TOOLS,
|
|
38
|
+
deniedToolIds: OPERATOR_CHAT_DENIED_TOOLS,
|
|
39
|
+
deniedOperatorActions: OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS,
|
|
40
|
+
builtinExploreToolIds: OPERATOR_CHAT_BUILTIN_EXPLORE_TOOL_IDS,
|
|
41
|
+
listTools: () => OPERATOR_CHAT_ALLOWED_TOOLS,
|
|
42
|
+
tryActivateTool: (toolId) => {
|
|
43
|
+
// Explore tools (bash/read/grep/find/ls) are run by the SDK's built-in
|
|
44
|
+
// tool registry, not by the operator dispatcher. tryActivateTool only
|
|
45
|
+
// classifies operator actions here, but it must NOT deny an explore
|
|
46
|
+
// tool — that would contradict the real session. Deny only write tools
|
|
47
|
+
// and report unknowns.
|
|
48
|
+
const explore = OPERATOR_CHAT_BUILTIN_EXPLORE_TOOL_IDS.includes(toolId);
|
|
49
|
+
if (explore) {
|
|
50
|
+
return { ok: true, toolId };
|
|
51
|
+
}
|
|
52
|
+
const decision = authorizeOperatorChatTool(toolId);
|
|
53
|
+
if (decision.ok)
|
|
54
|
+
active.add(decision.toolId);
|
|
55
|
+
return decision;
|
|
56
|
+
},
|
|
57
|
+
setActiveToolsByName: (names) => {
|
|
58
|
+
assertNoWriteToolInList(names);
|
|
59
|
+
const { allowed, denied } = filterOperatorChatTools(names);
|
|
60
|
+
active = new Set(allowed);
|
|
61
|
+
return { active: [...active], denied };
|
|
62
|
+
},
|
|
63
|
+
allowsEnvToolRestore: false,
|
|
64
|
+
hasBash: true,
|
|
65
|
+
};
|
|
66
|
+
}
|