operatr 0.12.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/LICENSE +21 -0
- package/README.md +87 -0
- package/bin/operator.js +14 -0
- package/package.json +49 -0
- package/public/assets/arc-C3oZj6HF.js +1 -0
- package/public/assets/architectureDiagram-3BPJPVTR-BFENfeP2.js +36 -0
- package/public/assets/blockDiagram-GPEHLZMM-C1A8KgJE.js +132 -0
- package/public/assets/c4Diagram-AAUBKEIU-03jekykM.js +10 -0
- package/public/assets/channel-BFxhh-e6.js +1 -0
- package/public/assets/chunk-2J33WTMH-Cd16JLFK.js +1 -0
- package/public/assets/chunk-4BX2VUAB-70lJk7KH.js +1 -0
- package/public/assets/chunk-55IACEB6-DzNNKRy6.js +1 -0
- package/public/assets/chunk-727SXJPM-CZjd3AP5.js +206 -0
- package/public/assets/chunk-AQP2D5EJ-B_48wDVV.js +231 -0
- package/public/assets/chunk-FMBD7UC4-u93wErxL.js +15 -0
- package/public/assets/chunk-ND2GUHAM-GWZJNHoC.js +1 -0
- package/public/assets/chunk-QZHKN3VN-BHGAVy4h.js +1 -0
- package/public/assets/classDiagram-4FO5ZUOK-BWxq3avf.js +1 -0
- package/public/assets/classDiagram-v2-Q7XG4LA2-BWxq3avf.js +1 -0
- package/public/assets/cose-bilkent-S5V4N54A-HdalsDiB.js +1 -0
- package/public/assets/cytoscape.esm-DTSO7Bv0.js +331 -0
- package/public/assets/dagre-BM42HDAG-DlIcXTjT.js +4 -0
- package/public/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/public/assets/diagram-2AECGRRQ-D0GHawOW.js +43 -0
- package/public/assets/diagram-5GNKFQAL-Cd36guSH.js +10 -0
- package/public/assets/diagram-KO2AKTUF-DtYM_ie4.js +3 -0
- package/public/assets/diagram-LMA3HP47-2KIpJ02P.js +24 -0
- package/public/assets/diagram-OG6HWLK6-DFwD1qiv.js +24 -0
- package/public/assets/erDiagram-TEJ5UH35-DslM2UDd.js +85 -0
- package/public/assets/flowDiagram-I6XJVG4X-DGGy4pet.js +162 -0
- package/public/assets/ganttDiagram-6RSMTGT7-CPWMAKNx.js +292 -0
- package/public/assets/gitGraphDiagram-PVQCEYII-DPlcGM7A.js +106 -0
- package/public/assets/graph-CAnANduQ.js +1 -0
- package/public/assets/index-C3Ld66t2.js +348 -0
- package/public/assets/index-CglXVmBD.css +32 -0
- package/public/assets/infoDiagram-5YYISTIA-BvfzdQ33.js +2 -0
- package/public/assets/init-Gi6I4Gst.js +1 -0
- package/public/assets/ishikawaDiagram-YF4QCWOH-BXBtXmNa.js +70 -0
- package/public/assets/journeyDiagram-JHISSGLW-0k6NYR9M.js +139 -0
- package/public/assets/kanban-definition-UN3LZRKU-Br-pJVoB.js +89 -0
- package/public/assets/katex-C5jXJg4s.js +257 -0
- package/public/assets/layout-DGIYPm2g.js +1 -0
- package/public/assets/linear-Dw294nF-.js +1 -0
- package/public/assets/mermaid.core-890uePvN.js +309 -0
- package/public/assets/mindmap-definition-RKZ34NQL-r7qhn-Fa.js +96 -0
- package/public/assets/ordinal-Cboi1Yqb.js +1 -0
- package/public/assets/pieDiagram-4H26LBE5-B4p_YihD.js +30 -0
- package/public/assets/quadrantDiagram-W4KKPZXB-DYJjK2r-.js +7 -0
- package/public/assets/requirementDiagram-4Y6WPE33-kRWJDus9.js +84 -0
- package/public/assets/sankeyDiagram-5OEKKPKP-Cs0dDmxJ.js +40 -0
- package/public/assets/sequenceDiagram-3UESZ5HK-e3s6dVA4.js +162 -0
- package/public/assets/stateDiagram-AJRCARHV-CpVW_jun.js +1 -0
- package/public/assets/stateDiagram-v2-BHNVJYJU-BZ88_i1s.js +1 -0
- package/public/assets/timeline-definition-PNZ67QCA-BXF5ufP-.js +120 -0
- package/public/assets/vennDiagram-CIIHVFJN-DhA6SmOv.js +34 -0
- package/public/assets/wardley-L42UT6IY-DwnN1FeQ.js +173 -0
- package/public/assets/wardleyDiagram-YWT4CUSO-C4y3XA-b.js +78 -0
- package/public/assets/xychartDiagram-2RQKCTM6-CrWgrBy2.js +7 -0
- package/public/index.html +19 -0
- package/public/operator-devtools.iife.js +416 -0
- package/public/operator-devtools.js +5870 -0
- package/server/index.js +9458 -0
- package/server/runner.js +3799 -0
package/server/runner.js
ADDED
|
@@ -0,0 +1,3799 @@
|
|
|
1
|
+
import { createRequire as __cr } from 'module'; const require = __cr(import.meta.url);
|
|
2
|
+
|
|
3
|
+
// apps/server/src/runner/main.ts
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import { promises as fs6, readFileSync } from "node:fs";
|
|
6
|
+
|
|
7
|
+
// packages/shared/src/types.ts
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
var LANE_IDS = [
|
|
10
|
+
"backlog",
|
|
11
|
+
"discovery",
|
|
12
|
+
"architecture",
|
|
13
|
+
"implementation",
|
|
14
|
+
"local-testing",
|
|
15
|
+
"pr-review",
|
|
16
|
+
"ci",
|
|
17
|
+
"prod-testing",
|
|
18
|
+
"done"
|
|
19
|
+
];
|
|
20
|
+
var TASK_ENTRY_LANES = ["backlog", "discovery", "architecture", "implementation"];
|
|
21
|
+
var LANES = [
|
|
22
|
+
{ id: "backlog", title: "Backlog", blurb: "Ideas and to-do. No agent yet.", automated: false },
|
|
23
|
+
{ id: "discovery", title: "Design", blurb: "Product discovery for larger features: clarifying questions, draft descriptions, rough wireframes, approach trade-offs.", automated: true },
|
|
24
|
+
{ id: "architecture", title: "Arch", blurb: "Agent proposes an implementation design.", automated: true },
|
|
25
|
+
{ id: "implementation", title: "Build", blurb: "Agent implements in an isolated worktree.", automated: true },
|
|
26
|
+
{ id: "local-testing", title: "Test", blurb: "Extensive local testing and polish.", automated: true },
|
|
27
|
+
{ id: "pr-review", title: "Review", blurb: "Independent agent reviews, then opens a PR.", automated: true },
|
|
28
|
+
{ id: "ci", title: "Merge", blurb: "Merge and babysit the pipeline to green.", automated: true },
|
|
29
|
+
{ id: "prod-testing", title: "Verify", blurb: "Verify the feature live; nothing broke.", automated: true },
|
|
30
|
+
{ id: "done", title: "Done", blurb: "Complete.", automated: false }
|
|
31
|
+
];
|
|
32
|
+
var EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
|
33
|
+
var TASK_SIZES = ["xs", "s", "m", "l", "xl"];
|
|
34
|
+
function effortToSize(effort) {
|
|
35
|
+
switch (effort) {
|
|
36
|
+
case "low":
|
|
37
|
+
return "s";
|
|
38
|
+
case "medium":
|
|
39
|
+
return "m";
|
|
40
|
+
case "high":
|
|
41
|
+
return "l";
|
|
42
|
+
case "xhigh":
|
|
43
|
+
case "max":
|
|
44
|
+
return "xl";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function taskScopeSize(t) {
|
|
48
|
+
return t.size ?? (t.effort ? effortToSize(t.effort) : "m");
|
|
49
|
+
}
|
|
50
|
+
var STEP_GATES = ["ask", "policy", "auto"];
|
|
51
|
+
var LEGACY_STEP_GATES = {
|
|
52
|
+
never: "ask",
|
|
53
|
+
decide: "policy",
|
|
54
|
+
always: "auto"
|
|
55
|
+
};
|
|
56
|
+
var DEFAULT_STEP_GATES = {
|
|
57
|
+
backlog: "policy",
|
|
58
|
+
discovery: "policy",
|
|
59
|
+
architecture: "policy",
|
|
60
|
+
implementation: "policy",
|
|
61
|
+
"local-testing": "policy",
|
|
62
|
+
"pr-review": "policy",
|
|
63
|
+
ci: "policy",
|
|
64
|
+
"prod-testing": "policy",
|
|
65
|
+
done: "policy"
|
|
66
|
+
};
|
|
67
|
+
var PERMISSION_MODES = [
|
|
68
|
+
"default",
|
|
69
|
+
"acceptEdits",
|
|
70
|
+
"auto",
|
|
71
|
+
"plan",
|
|
72
|
+
"dontAsk",
|
|
73
|
+
"bypassPermissions"
|
|
74
|
+
];
|
|
75
|
+
var DEV_SPEEDS = ["startup", "company", "enterprise"];
|
|
76
|
+
var IFRAME_CACHING = ["none", "last-open", "eager", "all-active"];
|
|
77
|
+
var DEFAULT_IFRAME_CACHING = "eager";
|
|
78
|
+
var FORK_KEY_SEP = "::fork::";
|
|
79
|
+
function parentTaskId(key) {
|
|
80
|
+
const i = key.indexOf(FORK_KEY_SEP);
|
|
81
|
+
return i >= 0 ? key.slice(0, i) : key;
|
|
82
|
+
}
|
|
83
|
+
var ForkTabSchema = z.object({
|
|
84
|
+
key: z.string(),
|
|
85
|
+
sessionId: z.string().catch(""),
|
|
86
|
+
label: z.string().catch(""),
|
|
87
|
+
createdAt: z.string().catch(""),
|
|
88
|
+
model: z.string().optional().catch(void 0),
|
|
89
|
+
effort: z.enum(EFFORT_LEVELS).optional().catch(void 0),
|
|
90
|
+
permissionMode: z.enum(PERMISSION_MODES).optional().catch(void 0),
|
|
91
|
+
autonomous: z.boolean().optional().catch(void 0)
|
|
92
|
+
});
|
|
93
|
+
var TaskFrontmatterSchema = z.object({
|
|
94
|
+
id: z.string().optional().catch(void 0),
|
|
95
|
+
title: z.string().optional().catch(void 0),
|
|
96
|
+
shortDescription: z.string().optional().catch(void 0),
|
|
97
|
+
order: z.number().optional().catch(void 0),
|
|
98
|
+
status: z.enum(LANE_IDS).optional().catch(void 0),
|
|
99
|
+
sessionId: z.string().optional().catch(void 0),
|
|
100
|
+
worktree: z.string().optional().catch(void 0),
|
|
101
|
+
branch: z.string().optional().catch(void 0),
|
|
102
|
+
model: z.string().optional().catch(void 0),
|
|
103
|
+
effort: z.enum(EFFORT_LEVELS).optional().catch(void 0),
|
|
104
|
+
size: z.enum(TASK_SIZES).optional().catch(void 0),
|
|
105
|
+
permissionMode: z.enum(PERMISSION_MODES).optional().catch(void 0),
|
|
106
|
+
autonomous: z.boolean().optional().catch(void 0),
|
|
107
|
+
forks: z.array(ForkTabSchema).optional().catch(void 0),
|
|
108
|
+
prUrl: z.string().optional().catch(void 0),
|
|
109
|
+
prNumber: z.number().optional().catch(void 0),
|
|
110
|
+
createdAt: z.string().optional().catch(void 0),
|
|
111
|
+
/** ISO timestamp of when the task first moved to the done lane. */
|
|
112
|
+
doneAt: z.string().optional().catch(void 0),
|
|
113
|
+
/** ISO timestamp of when the task first left backlog into any swimlane. */
|
|
114
|
+
workflowEnteredAt: z.string().optional().catch(void 0),
|
|
115
|
+
/** ISO timestamp of the last orchestrator update_task groom call. */
|
|
116
|
+
lastGroomedAt: z.string().optional().catch(void 0),
|
|
117
|
+
/** ISO timestamp of the last update_task_doc append (phase agent changed the spec). */
|
|
118
|
+
lastDocUpdatedAt: z.string().optional().catch(void 0),
|
|
119
|
+
/** User-drag crash-safety marker — see `Task.pendingKickoff`. */
|
|
120
|
+
pendingKickoff: z.enum(LANE_IDS).optional().catch(void 0),
|
|
121
|
+
/** Last lane whose phase prompt reached the runner — see `Task.kickedLane`. */
|
|
122
|
+
kickedLane: z.enum(LANE_IDS).optional().catch(void 0),
|
|
123
|
+
/** Groomer-proposed first swimlane — see `Task.proposedLane`. */
|
|
124
|
+
proposedLane: z.enum(LANE_IDS).optional().catch(void 0),
|
|
125
|
+
/** Orchestrator is grooming/routing this idea — see `Task.grooming`. */
|
|
126
|
+
grooming: z.boolean().optional().catch(void 0)
|
|
127
|
+
}).passthrough();
|
|
128
|
+
var StepGatesFrontmatterSchema = z.record(z.string(), z.unknown()).transform((raw) => {
|
|
129
|
+
const out = {};
|
|
130
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
131
|
+
const gate = LEGACY_STEP_GATES[v] ?? v;
|
|
132
|
+
if (LANE_IDS.includes(k) && STEP_GATES.includes(gate)) out[k] = gate;
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}).catch({});
|
|
136
|
+
var PhaseHooksFrontmatterSchema = z.record(z.string(), z.unknown()).transform((raw) => {
|
|
137
|
+
const out = {};
|
|
138
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
139
|
+
if (LANE_IDS.includes(k) && typeof v === "string" && v.trim()) {
|
|
140
|
+
out[k] = v.trim();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}).catch({});
|
|
145
|
+
var BoardFrontmatterSchema = z.object({
|
|
146
|
+
trunk: z.string().optional().catch(void 0),
|
|
147
|
+
reviewMode: z.enum(["local", "remote"]).catch("local"),
|
|
148
|
+
autoExpand: z.boolean().catch(true),
|
|
149
|
+
devSpeed: z.enum(DEV_SPEEDS).catch("enterprise"),
|
|
150
|
+
iframeCaching: z.enum(IFRAME_CACHING).catch(DEFAULT_IFRAME_CACHING),
|
|
151
|
+
orchestratorSessionId: z.string().optional().catch(void 0),
|
|
152
|
+
orchestratorModel: z.string().optional().catch(void 0),
|
|
153
|
+
orchestratorEffort: z.enum(EFFORT_LEVELS).optional().catch(void 0),
|
|
154
|
+
orchestratorPermissionMode: z.enum(PERMISSION_MODES).optional().catch(void 0),
|
|
155
|
+
orchestratorForks: z.array(ForkTabSchema).optional().catch(void 0),
|
|
156
|
+
stepGates: StepGatesFrontmatterSchema.default({}),
|
|
157
|
+
phaseHooks: PhaseHooksFrontmatterSchema.default({}),
|
|
158
|
+
/** One-time command run in a JUST-CREATED task worktree (deps install etc.),
|
|
159
|
+
* before the first phase prompt. See Project.worktreeSetup. */
|
|
160
|
+
worktreeSetup: z.string().optional().catch(void 0),
|
|
161
|
+
devCommand: z.string().optional().catch(void 0)
|
|
162
|
+
}).passthrough();
|
|
163
|
+
var isOrchestratorKey = (key) => key.startsWith("orch:");
|
|
164
|
+
var ClientMessageSchema = z.discriminatedUnion("kind", [
|
|
165
|
+
// `afterSeq`: the stream's last seen seq cursor — the server replays only
|
|
166
|
+
// events after it (gap-free reconnect); omitted → full snapshot replay.
|
|
167
|
+
z.object({
|
|
168
|
+
kind: z.literal("subscribe_agent"),
|
|
169
|
+
taskId: z.string(),
|
|
170
|
+
afterSeq: z.number().int().nonnegative().optional()
|
|
171
|
+
}),
|
|
172
|
+
// Run logs: stream one run's output to the open panel (one at a time).
|
|
173
|
+
z.object({ kind: z.literal("subscribe_run"), taskId: z.string(), runId: z.string() }),
|
|
174
|
+
z.object({ kind: z.literal("unsubscribe_run"), taskId: z.string(), runId: z.string() }),
|
|
175
|
+
// Scope this tab's board stream to one project, so a tab governing project A
|
|
176
|
+
// doesn't receive (and clobber its view with) project B's board broadcasts.
|
|
177
|
+
// Re-sent after reconnect so the scoping survives a server restart.
|
|
178
|
+
z.object({ kind: z.literal("subscribe_project"), projectId: z.string() }),
|
|
179
|
+
// Run an ad-hoc shell command in the task's worktree (one at a time), or kill it.
|
|
180
|
+
z.object({ kind: z.literal("run_command"), taskId: z.string(), command: z.string() }),
|
|
181
|
+
z.object({ kind: z.literal("kill_command"), taskId: z.string() }),
|
|
182
|
+
// Dictation: open the upstream relay, stream base64 PCM16 chunks, then stop.
|
|
183
|
+
z.object({ kind: z.literal("dictation_start") }),
|
|
184
|
+
z.object({ kind: z.literal("dictation_audio"), audio: z.string() }),
|
|
185
|
+
z.object({ kind: z.literal("dictation_stop") }),
|
|
186
|
+
// Result of a devtools_command, echoed back with its commandId.
|
|
187
|
+
z.object({
|
|
188
|
+
kind: z.literal("devtools_result"),
|
|
189
|
+
taskId: z.string(),
|
|
190
|
+
commandId: z.string(),
|
|
191
|
+
ok: z.boolean(),
|
|
192
|
+
result: z.unknown().optional(),
|
|
193
|
+
error: z.string().optional()
|
|
194
|
+
})
|
|
195
|
+
]);
|
|
196
|
+
var OpenProjectBody = z.object({
|
|
197
|
+
path: z.string().min(1),
|
|
198
|
+
trunk: z.string().optional()
|
|
199
|
+
}).strict();
|
|
200
|
+
var UpdateProjectBody = z.object({
|
|
201
|
+
name: z.string().min(1).optional(),
|
|
202
|
+
trunk: z.string().min(1).optional()
|
|
203
|
+
}).strict();
|
|
204
|
+
var ProjectSettingsBody = z.object({
|
|
205
|
+
reviewMode: z.enum(["local", "remote"]).optional(),
|
|
206
|
+
trunk: z.string().optional(),
|
|
207
|
+
autoExpand: z.boolean().optional(),
|
|
208
|
+
devSpeed: z.enum(DEV_SPEEDS).optional(),
|
|
209
|
+
iframeCaching: z.enum(IFRAME_CACHING).optional(),
|
|
210
|
+
stepGates: z.record(z.enum(LANE_IDS), z.enum(STEP_GATES)).optional(),
|
|
211
|
+
// Per-lane harness command. Empty string clears the hook for that lane.
|
|
212
|
+
phaseHooks: z.record(z.enum(LANE_IDS), z.string()).optional(),
|
|
213
|
+
// One-time worktree-creation hook. Empty string clears it.
|
|
214
|
+
worktreeSetup: z.string().optional()
|
|
215
|
+
}).strict();
|
|
216
|
+
var AGENT_PROMPT_IDS = ["groom", "orchestrator"];
|
|
217
|
+
var CustomizePromptBody = z.object({
|
|
218
|
+
scope: z.enum(["project", "user"]),
|
|
219
|
+
lane: z.enum(LANE_IDS).optional(),
|
|
220
|
+
agent: z.enum(AGENT_PROMPT_IDS).optional()
|
|
221
|
+
}).strict().refine((b) => b.lane === void 0 !== (b.agent === void 0), {
|
|
222
|
+
message: "Provide exactly one of `lane` or `agent`."
|
|
223
|
+
});
|
|
224
|
+
var CreateTaskBody = z.object({
|
|
225
|
+
// Often empty at creation: the user's text goes into the description/body and a
|
|
226
|
+
// short title is derived during grooming. Kept for the orchestrator/agent to set.
|
|
227
|
+
title: z.string().default(""),
|
|
228
|
+
shortDescription: z.string().default(""),
|
|
229
|
+
/** Lane to create the task in. Defaults to backlog; an in-progress lane starts that phase immediately. */
|
|
230
|
+
toLane: z.enum(TASK_ENTRY_LANES).optional(),
|
|
231
|
+
/**
|
|
232
|
+
* Kick off immediately: create in the backlog, then have the orchestrator
|
|
233
|
+
* estimate the task's scope and start it at the first phase that scope needs
|
|
234
|
+
* (skipping e.g. discovery/architecture for small tasks). Only meaningful for
|
|
235
|
+
* backlog creation — an explicit in-progress `toLane` already starts at once.
|
|
236
|
+
*/
|
|
237
|
+
kickoff: z.boolean().optional()
|
|
238
|
+
}).strict();
|
|
239
|
+
var MoveTaskBody = z.object({
|
|
240
|
+
toLane: z.enum(LANE_IDS),
|
|
241
|
+
toOrder: z.number().int().nonnegative().optional(),
|
|
242
|
+
/** Optional drag-time instruction from the user, injected into the rerun
|
|
243
|
+
* phase's prompt as part of `{{userMoveContext}}`. */
|
|
244
|
+
userNote: z.string().max(4e3).optional()
|
|
245
|
+
}).strict();
|
|
246
|
+
var SendMessageBody = z.object({
|
|
247
|
+
text: z.string().min(1)
|
|
248
|
+
}).strict();
|
|
249
|
+
var SetControlsBody = z.object({
|
|
250
|
+
model: z.string().optional(),
|
|
251
|
+
effort: z.enum(EFFORT_LEVELS).optional(),
|
|
252
|
+
size: z.enum(TASK_SIZES).optional(),
|
|
253
|
+
permissionMode: z.enum(PERMISSION_MODES).optional(),
|
|
254
|
+
/** Target a fork session (its stream key) instead of the main task session.
|
|
255
|
+
* Omitted/equal to the task id → the main session. `size` is task-level and
|
|
256
|
+
* ignores this. */
|
|
257
|
+
streamKey: z.string().optional()
|
|
258
|
+
}).strict();
|
|
259
|
+
var PermissionDecisionBody = z.object({
|
|
260
|
+
requestId: z.string(),
|
|
261
|
+
decision: z.enum(["allow", "deny"]),
|
|
262
|
+
message: z.string().optional()
|
|
263
|
+
}).strict();
|
|
264
|
+
var ClarificationAnswerBody = z.object({
|
|
265
|
+
requestId: z.string(),
|
|
266
|
+
answer: z.string()
|
|
267
|
+
}).strict();
|
|
268
|
+
var AskQuestionAnswerBody = z.object({
|
|
269
|
+
requestId: z.string(),
|
|
270
|
+
// Map of question text -> chosen answer (multi-select answers comma-joined).
|
|
271
|
+
// Omitted entirely when the user cancels/dismisses the prompt (the tool is denied).
|
|
272
|
+
answers: z.record(z.string()).optional()
|
|
273
|
+
}).strict();
|
|
274
|
+
var AdvanceDecisionBody = z.object({
|
|
275
|
+
requestId: z.string(),
|
|
276
|
+
accept: z.boolean(),
|
|
277
|
+
toLane: z.enum(LANE_IDS).optional()
|
|
278
|
+
}).strict();
|
|
279
|
+
var GlobalSettingsBody = z.object({
|
|
280
|
+
/** ElevenLabs API key for dictation. Empty string clears it. */
|
|
281
|
+
elevenLabsApiKey: z.string().optional(),
|
|
282
|
+
/**
|
|
283
|
+
* When true, pass `settingSources: ["user","project","local"]` to agent sessions
|
|
284
|
+
* so they inherit the user's ~/.claude settings (model defaults, hooks, autoCompact…).
|
|
285
|
+
* When false (default), only project/local sources are used and autoCompact is
|
|
286
|
+
* enabled unconditionally so agents self-manage their context window.
|
|
287
|
+
*/
|
|
288
|
+
passthroughUserSettings: z.boolean().optional(),
|
|
289
|
+
/** Comma-separated spoken keywords that submit the current dictation buffer. Default: "shazam". */
|
|
290
|
+
dictationSubmitKeywords: z.string().optional()
|
|
291
|
+
}).strict();
|
|
292
|
+
var AddAccountBody = z.object({
|
|
293
|
+
label: z.string().min(1),
|
|
294
|
+
token: z.string().min(1)
|
|
295
|
+
}).strict();
|
|
296
|
+
var ComposerPutBody = z.object({
|
|
297
|
+
key: z.string().min(1),
|
|
298
|
+
draft: z.string().default(""),
|
|
299
|
+
queue: z.array(z.string()).default([]),
|
|
300
|
+
pending: z.array(z.string()).default([])
|
|
301
|
+
}).strict();
|
|
302
|
+
var ClaimPendingBody = z.object({ key: z.string().min(1) }).strict();
|
|
303
|
+
var ForkBody = z.object({
|
|
304
|
+
/** Stream key being forked (taskId for the main session, or a fork key). */
|
|
305
|
+
sourceKey: z.string().optional(),
|
|
306
|
+
/** First turn sent to the fork (spawns its runner). */
|
|
307
|
+
message: z.string().optional(),
|
|
308
|
+
/** Start a fresh session (no copied transcript/feed) instead of a fork. The
|
|
309
|
+
* new tab still inherits the source session's model/effort/permission and the
|
|
310
|
+
* same cwd (task worktree / project root). */
|
|
311
|
+
blank: z.boolean().optional()
|
|
312
|
+
}).strict();
|
|
313
|
+
var RenameBody = z.object({ name: z.string().trim().min(1) }).strict();
|
|
314
|
+
var RewindBody = z.object({
|
|
315
|
+
toUuid: z.string().min(1),
|
|
316
|
+
preview: z.string().optional(),
|
|
317
|
+
index: z.number().int().nonnegative().optional()
|
|
318
|
+
}).strict();
|
|
319
|
+
|
|
320
|
+
// packages/shared/src/fastTrack.ts
|
|
321
|
+
var AUTOMATED_LANES = LANES.filter((l) => l.automated).map((l) => l.id);
|
|
322
|
+
function sizeOf(size) {
|
|
323
|
+
return size ?? "m";
|
|
324
|
+
}
|
|
325
|
+
function skippableLanes(devSpeed, size) {
|
|
326
|
+
const s = sizeOf(size);
|
|
327
|
+
if (devSpeed === "enterprise") {
|
|
328
|
+
return /* @__PURE__ */ new Set();
|
|
329
|
+
}
|
|
330
|
+
if (devSpeed === "company") {
|
|
331
|
+
if (s === "xs" || s === "s")
|
|
332
|
+
return /* @__PURE__ */ new Set(["discovery", "architecture", "local-testing", "prod-testing"]);
|
|
333
|
+
if (s === "m") return /* @__PURE__ */ new Set(["discovery", "architecture", "prod-testing"]);
|
|
334
|
+
return /* @__PURE__ */ new Set(["prod-testing"]);
|
|
335
|
+
}
|
|
336
|
+
if (s === "xs" || s === "s" || s === "m")
|
|
337
|
+
return /* @__PURE__ */ new Set([
|
|
338
|
+
"discovery",
|
|
339
|
+
"architecture",
|
|
340
|
+
"local-testing",
|
|
341
|
+
"pr-review",
|
|
342
|
+
"prod-testing"
|
|
343
|
+
]);
|
|
344
|
+
if (s === "l") return /* @__PURE__ */ new Set(["discovery", "prod-testing"]);
|
|
345
|
+
return /* @__PURE__ */ new Set(["prod-testing"]);
|
|
346
|
+
}
|
|
347
|
+
function plannedLanes(devSpeed, size) {
|
|
348
|
+
const skip = skippableLanes(devSpeed, size);
|
|
349
|
+
return AUTOMATED_LANES.filter((l) => !skip.has(l));
|
|
350
|
+
}
|
|
351
|
+
function firstPlannedLane(devSpeed, size) {
|
|
352
|
+
const first = plannedLanes(devSpeed, size)[0];
|
|
353
|
+
if (!first)
|
|
354
|
+
throw new Error(
|
|
355
|
+
`firstPlannedLane: empty planned path for devSpeed=${devSpeed} size=${size ?? "m"}`
|
|
356
|
+
);
|
|
357
|
+
return first;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// packages/shared/src/taskOrdering.ts
|
|
361
|
+
function laneSortKey(task, lane) {
|
|
362
|
+
if (lane === "backlog") return task.createdAt;
|
|
363
|
+
if (lane === "done") return task.doneAt ?? task.createdAt;
|
|
364
|
+
return task.workflowEnteredAt ?? task.createdAt;
|
|
365
|
+
}
|
|
366
|
+
function compareTasksInLane(lane, a, b) {
|
|
367
|
+
return laneSortKey(b, lane).localeCompare(laneSortKey(a, lane)) || b.id.localeCompare(a.id);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// packages/shared/src/taskFields.ts
|
|
371
|
+
var TASK_BODY_FIELDS = [
|
|
372
|
+
{ key: "problem", heading: "Problem" },
|
|
373
|
+
{ key: "context", heading: "Context" },
|
|
374
|
+
{ key: "acceptance", heading: "Acceptance criteria" },
|
|
375
|
+
/** The independent-test brief — what the test phase verifies, with NO
|
|
376
|
+
* implementation detail. `expectedResult` defaults to the grooming-set
|
|
377
|
+
* acceptance criteria and may be refined at end of build; `affectedAreas` is
|
|
378
|
+
* the high-level "which areas changed" (not how) the build agent records via
|
|
379
|
+
* `set_test_brief` before handing off to the fresh tester session. */
|
|
380
|
+
{ key: "expectedResult", heading: "Expected result" },
|
|
381
|
+
{ key: "affectedAreas", heading: "Affected areas" },
|
|
382
|
+
{ key: "clarifications", heading: "Clarifications" },
|
|
383
|
+
/** Per-phase work log — discovery/architecture/impl findings (update_task_doc
|
|
384
|
+
* appends here). Kept structured so phase output never hand-edits the
|
|
385
|
+
* definition fields above it. */
|
|
386
|
+
{ key: "notes", heading: "Notes" }
|
|
387
|
+
];
|
|
388
|
+
var APPEND_FIELDS = ["clarifications", "notes"];
|
|
389
|
+
var HEADING_TO_KEY = new Map(
|
|
390
|
+
TASK_BODY_FIELDS.map((f) => [f.heading.toLowerCase(), f.key])
|
|
391
|
+
);
|
|
392
|
+
function parseTaskBody(body) {
|
|
393
|
+
const fields = {};
|
|
394
|
+
let curKey = null;
|
|
395
|
+
let unknownHeading = null;
|
|
396
|
+
let buf = [];
|
|
397
|
+
const flush = () => {
|
|
398
|
+
const text = buf.join("\n").trim();
|
|
399
|
+
buf = [];
|
|
400
|
+
if (curKey) {
|
|
401
|
+
if (text) fields[curKey] = fields[curKey] ? `${fields[curKey]}
|
|
402
|
+
|
|
403
|
+
${text}` : text;
|
|
404
|
+
} else if (unknownHeading) {
|
|
405
|
+
const chunk = text ? `## ${unknownHeading}
|
|
406
|
+
|
|
407
|
+
${text}` : `## ${unknownHeading}`;
|
|
408
|
+
fields.notes = fields.notes ? `${fields.notes}
|
|
409
|
+
|
|
410
|
+
${chunk}` : chunk;
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
for (const line of body.split("\n")) {
|
|
414
|
+
const m = /^#{2,6}\s+(.+?)\s*$/.exec(line);
|
|
415
|
+
if (m) {
|
|
416
|
+
flush();
|
|
417
|
+
const key = HEADING_TO_KEY.get(m[1].toLowerCase());
|
|
418
|
+
curKey = key ?? null;
|
|
419
|
+
unknownHeading = key ? null : m[1];
|
|
420
|
+
} else {
|
|
421
|
+
buf.push(line);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
flush();
|
|
425
|
+
return fields;
|
|
426
|
+
}
|
|
427
|
+
function serializeTaskBody(fields) {
|
|
428
|
+
const parts = [];
|
|
429
|
+
for (const { key, heading } of TASK_BODY_FIELDS) {
|
|
430
|
+
const v = fields[key]?.trim();
|
|
431
|
+
if (v) parts.push(`## ${heading}
|
|
432
|
+
|
|
433
|
+
${v}`);
|
|
434
|
+
}
|
|
435
|
+
return parts.length ? parts.join("\n\n") + "\n" : "";
|
|
436
|
+
}
|
|
437
|
+
function patchTaskBody(body, patch, append = APPEND_FIELDS) {
|
|
438
|
+
const fields = parseTaskBody(body);
|
|
439
|
+
const appendSet = new Set(append);
|
|
440
|
+
for (const { key } of TASK_BODY_FIELDS) {
|
|
441
|
+
const v = patch[key];
|
|
442
|
+
if (v === void 0) continue;
|
|
443
|
+
const val = v.trim();
|
|
444
|
+
if (appendSet.has(key) && fields[key] && val) fields[key] = `${fields[key]}
|
|
445
|
+
|
|
446
|
+
${val}`;
|
|
447
|
+
else fields[key] = val;
|
|
448
|
+
}
|
|
449
|
+
return serializeTaskBody(fields);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// packages/shared/src/feed/identity.ts
|
|
453
|
+
var enc = new TextEncoder();
|
|
454
|
+
|
|
455
|
+
// apps/server/src/runner/main.ts
|
|
456
|
+
import { nanoid as nanoid2 } from "nanoid";
|
|
457
|
+
|
|
458
|
+
// apps/server/src/agent/runtime.ts
|
|
459
|
+
import {
|
|
460
|
+
query,
|
|
461
|
+
forkSession
|
|
462
|
+
} from "@anthropic-ai/claude-agent-sdk";
|
|
463
|
+
import { nanoid } from "nanoid";
|
|
464
|
+
|
|
465
|
+
// apps/server/src/agent/toolPolicy.ts
|
|
466
|
+
import path from "node:path";
|
|
467
|
+
var SELF_SCHEDULE_TOOLS = /* @__PURE__ */ new Set(["ScheduleWakeup", "CronCreate", "CronDelete", "CronList", "RemoteTrigger"]);
|
|
468
|
+
var SELF_SCHEDULE_MESSAGE = "Refused: don't self-schedule or poll on a timer in an Operator task. When you start a tracked run (run_tests / start_run), just END YOUR TURN \u2014 Operator sends you the run's PASS/FAIL verdict as a message and re-invokes you automatically. If you're blocked on something else, call mcp__operator__propose_advance or mcp__operator__request_clarification and stop; the harness wakes you when there's work. ScheduleWakeup/Cron do not fire reliably in this runtime and only strand the task.";
|
|
469
|
+
var BACKGROUND_BASH_MESSAGE = "Refused: don't run Bash with run_in_background. Operator doesn't own a backgrounded shell, so it can't deliver the command's result back to you \u2014 your turn would end and the session would hang waiting forever. Run it in the FOREGROUND and wait this turn (up to 10m). For a test suite use mcp__operator__run_tests; for a long-lived process (dev server / preview) use mcp__operator__start_run \u2014 both are tracked and Operator sends you their verdict/exit as a message, so you can safely end your turn.";
|
|
470
|
+
var DANGEROUS_BASH = [
|
|
471
|
+
/\bpkill\b/,
|
|
472
|
+
/\bkillall\b/,
|
|
473
|
+
/\b(kill|lsof|fuser)\b[^\n]*\b(4317|5173)\b/,
|
|
474
|
+
/\b(4317|5173)\b[^\n]*\b(kill|lsof|fuser)\b/,
|
|
475
|
+
/\brm\s+-rf\s+(\/|~|\$HOME)(\s|$|\/)/,
|
|
476
|
+
/\b(shutdown|reboot|halt)\b/
|
|
477
|
+
];
|
|
478
|
+
var DANGER_MESSAGE = "Refused. Do not kill processes or ports you did not start: no pkill/killall, and never touch ports 4317/5173 or the Operator manager. Run the app on $OPERATOR_PORT and stop only the exact PID you started.";
|
|
479
|
+
var GIT_READONLY_SUBCMDS = /* @__PURE__ */ new Set([
|
|
480
|
+
"log",
|
|
481
|
+
"show",
|
|
482
|
+
"diff",
|
|
483
|
+
"status",
|
|
484
|
+
"rev-parse",
|
|
485
|
+
"cat-file",
|
|
486
|
+
"ls-files",
|
|
487
|
+
"ls-tree",
|
|
488
|
+
"blame",
|
|
489
|
+
"describe",
|
|
490
|
+
"for-each-ref",
|
|
491
|
+
"rev-list",
|
|
492
|
+
"shortlog",
|
|
493
|
+
"show-ref",
|
|
494
|
+
"name-rev",
|
|
495
|
+
"diff-tree",
|
|
496
|
+
"merge-base",
|
|
497
|
+
"grep",
|
|
498
|
+
"whatchanged",
|
|
499
|
+
"cherry",
|
|
500
|
+
"count-objects",
|
|
501
|
+
"reflog"
|
|
502
|
+
]);
|
|
503
|
+
var ESCAPE_MESSAGE = "Refused: that git command would WRITE to a checkout other than your worktree. You may READ another checkout (git -C <path> log/show/diff/status \u2026), but never check out, commit, merge, reset, push, or otherwise mutate outside your worktree \u2014 make changes in your worktree and let the harness merge them.";
|
|
504
|
+
function matchBash(input, patterns) {
|
|
505
|
+
const cmd = typeof input?.command === "string" ? input.command : "";
|
|
506
|
+
for (const re of patterns) if (re.test(cmd)) return cmd;
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
function dangerousCommand(input) {
|
|
510
|
+
return matchBash(input, DANGEROUS_BASH);
|
|
511
|
+
}
|
|
512
|
+
var BRANCH_MUTATING_LONG = /^--(delete|force|move|copy|set-upstream-to|unset-upstream|edit-description|create-reflog|track)(=|$)/;
|
|
513
|
+
var BRANCH_MUTATING_SHORT = /^-[a-zA-Z]*[dDmMcCft]/;
|
|
514
|
+
function isReadonlyBranchInvocation(args) {
|
|
515
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
516
|
+
if (tokens.length === 0) return true;
|
|
517
|
+
if (tokens.some((t) => BRANCH_MUTATING_LONG.test(t) || BRANCH_MUTATING_SHORT.test(t))) return false;
|
|
518
|
+
if (!tokens.every((t) => t.startsWith("-"))) return false;
|
|
519
|
+
return tokens.some((t) => t === "--show-current" || t === "--list" || t === "-l" || t === "-a" || t === "-r" || t === "-v" || t === "-vv");
|
|
520
|
+
}
|
|
521
|
+
function unquote(s) {
|
|
522
|
+
if (s.startsWith("'") && s.endsWith("'") || s.startsWith('"') && s.endsWith('"')) {
|
|
523
|
+
return s.slice(1, -1);
|
|
524
|
+
}
|
|
525
|
+
return s;
|
|
526
|
+
}
|
|
527
|
+
function targetsOwnWorktree(rawTarget, cwd) {
|
|
528
|
+
const target = unquote(rawTarget);
|
|
529
|
+
if (!target || /[$`]/.test(target)) return false;
|
|
530
|
+
const rel = path.relative(cwd, path.resolve(cwd, target));
|
|
531
|
+
return rel === "" || rel !== ".." && !rel.startsWith(".." + path.sep) && !path.isAbsolute(rel);
|
|
532
|
+
}
|
|
533
|
+
function worktreeEscapeCommand(input, cwd) {
|
|
534
|
+
const cmd = typeof input?.command === "string" ? input.command : "";
|
|
535
|
+
if (!cmd) return null;
|
|
536
|
+
if (/--git-dir\b/.test(cmd) || /--work-tree\b/.test(cmd)) return cmd;
|
|
537
|
+
if (!/\bgit\s+-C\b/.test(cmd)) return null;
|
|
538
|
+
const re = /\bgit\s+-C\s+('[^']*'|"[^"]*"|\S+)\s+([a-z][a-z-]*)[ \t]*([^;|&\n]*)/g;
|
|
539
|
+
let matched = false;
|
|
540
|
+
for (const m of cmd.matchAll(re)) {
|
|
541
|
+
matched = true;
|
|
542
|
+
if (targetsOwnWorktree(m[1], cwd)) continue;
|
|
543
|
+
if (GIT_READONLY_SUBCMDS.has(m[2])) continue;
|
|
544
|
+
if (m[2] === "branch" && isReadonlyBranchInvocation(m[3] ?? "")) continue;
|
|
545
|
+
return cmd;
|
|
546
|
+
}
|
|
547
|
+
return matched ? null : cmd;
|
|
548
|
+
}
|
|
549
|
+
var FILE_WRITE_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
550
|
+
function outOfWorktreeWrite(toolName, input, cwd) {
|
|
551
|
+
if (!FILE_WRITE_TOOLS.has(toolName)) return null;
|
|
552
|
+
const fp = input?.file_path ?? input?.notebook_path;
|
|
553
|
+
if (typeof fp !== "string" || fp === "") return null;
|
|
554
|
+
const abs = path.resolve(cwd, fp);
|
|
555
|
+
const rel = path.relative(cwd, abs);
|
|
556
|
+
const outside = rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel);
|
|
557
|
+
return outside ? abs : null;
|
|
558
|
+
}
|
|
559
|
+
var OUT_OF_WORKTREE_MESSAGE = (cwd, target) => `Refused: \`${target}\` is outside your worktree. You may only create or edit files inside \`${cwd}\` \u2014 your own isolated worktree. That path is in the user's live main checkout; editing it corrupts the running app. Make the change at the matching path inside your worktree instead, and never pass a file-writing tool an absolute path that points outside it. If you genuinely need to touch something outside the worktree, call mcp__operator__request_clarification.`;
|
|
560
|
+
function evaluateToolPolicy(toolName, input, cwd, opts) {
|
|
561
|
+
if (SELF_SCHEDULE_TOOLS.has(toolName)) {
|
|
562
|
+
return { message: SELF_SCHEDULE_MESSAGE, log: `Blocked self-scheduling tool: ${toolName}` };
|
|
563
|
+
}
|
|
564
|
+
if (toolName === "Bash" && input?.run_in_background === true) {
|
|
565
|
+
return { message: BACKGROUND_BASH_MESSAGE, log: "Blocked backgrounded Bash (run_in_background)" };
|
|
566
|
+
}
|
|
567
|
+
const escapee = outOfWorktreeWrite(toolName, input, cwd);
|
|
568
|
+
if (escapee) {
|
|
569
|
+
return { message: OUT_OF_WORKTREE_MESSAGE(cwd, escapee), log: `Blocked out-of-worktree write: ${escapee}` };
|
|
570
|
+
}
|
|
571
|
+
if (opts.autonomous && toolName === "Bash") {
|
|
572
|
+
const danger = dangerousCommand(input);
|
|
573
|
+
if (danger) return { message: DANGER_MESSAGE, log: `Blocked dangerous command: ${danger}` };
|
|
574
|
+
const escape = worktreeEscapeCommand(input, cwd);
|
|
575
|
+
if (escape) return { message: ESCAPE_MESSAGE, log: `Blocked worktree-escape command: ${escape}` };
|
|
576
|
+
}
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// apps/server/src/agent/runtime.ts
|
|
581
|
+
var DEFAULT_AUTO_ALLOW = [
|
|
582
|
+
"Read",
|
|
583
|
+
"Grep",
|
|
584
|
+
"Glob",
|
|
585
|
+
"LS",
|
|
586
|
+
"WebFetch",
|
|
587
|
+
"WebSearch",
|
|
588
|
+
"TodoWrite",
|
|
589
|
+
"NotebookRead",
|
|
590
|
+
"BashOutput"
|
|
591
|
+
];
|
|
592
|
+
var BASH_INLINE_ENV = {
|
|
593
|
+
BASH_DEFAULT_TIMEOUT_MS: "600000",
|
|
594
|
+
BASH_MAX_TIMEOUT_MS: "600000",
|
|
595
|
+
CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1"
|
|
596
|
+
};
|
|
597
|
+
var InputQueue = class {
|
|
598
|
+
buf = [];
|
|
599
|
+
waiters = [];
|
|
600
|
+
ended = false;
|
|
601
|
+
push(msg) {
|
|
602
|
+
const w = this.waiters.shift();
|
|
603
|
+
if (w) w({ value: msg, done: false });
|
|
604
|
+
else this.buf.push(msg);
|
|
605
|
+
}
|
|
606
|
+
end() {
|
|
607
|
+
this.ended = true;
|
|
608
|
+
let w;
|
|
609
|
+
while (w = this.waiters.shift()) w({ value: void 0, done: true });
|
|
610
|
+
}
|
|
611
|
+
async *[Symbol.asyncIterator]() {
|
|
612
|
+
for (; ; ) {
|
|
613
|
+
if (this.buf.length) {
|
|
614
|
+
yield this.buf.shift();
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
if (this.ended) return;
|
|
618
|
+
const r = await new Promise((res) => this.waiters.push(res));
|
|
619
|
+
if (r.done) return;
|
|
620
|
+
yield r.value;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
function userMessage(text) {
|
|
625
|
+
return {
|
|
626
|
+
type: "user",
|
|
627
|
+
parent_tool_use_id: null,
|
|
628
|
+
message: { role: "user", content: text }
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
var RATE_LIMIT_RE = /(?:\b(?:status|code|http)\b[^0-9]{0,12})(?:429|529)\b|\b(?:429|529)\b(?:\s+(?:too many requests|overloaded|server error|service unavailable))|overloaded|rate[ _-]?limit|temporarily limiting|too many requests/i;
|
|
632
|
+
function isRateLimitError(err) {
|
|
633
|
+
if (err == null) return false;
|
|
634
|
+
const status = err.status ?? err.statusCode;
|
|
635
|
+
if (status === 429 || status === 529) return true;
|
|
636
|
+
let text;
|
|
637
|
+
try {
|
|
638
|
+
text = typeof err === "string" ? err : [err.message, err.error?.message, err.subtype, JSON.stringify(err)].filter(Boolean).join(" ");
|
|
639
|
+
} catch {
|
|
640
|
+
text = String(err.message ?? "");
|
|
641
|
+
}
|
|
642
|
+
return RATE_LIMIT_RE.test(text);
|
|
643
|
+
}
|
|
644
|
+
var CONTEXT_OVERFLOW_RE = /prompt is too long|input is too long|too many (?:input )?tokens|maximum context length|context (?:window |length )?(?:exceeded|too long|limit exceeded)/i;
|
|
645
|
+
function isContextOverflowError(err) {
|
|
646
|
+
if (err == null) return false;
|
|
647
|
+
let text;
|
|
648
|
+
try {
|
|
649
|
+
text = typeof err === "string" ? err : [err.message, err.error?.message, err.subtype, JSON.stringify(err)].filter(Boolean).join(" ");
|
|
650
|
+
} catch {
|
|
651
|
+
text = String(err.message ?? "");
|
|
652
|
+
}
|
|
653
|
+
return CONTEXT_OVERFLOW_RE.test(text);
|
|
654
|
+
}
|
|
655
|
+
var SESSION_NOT_FOUND_RE = /no conversation found|conversation not found|session (?:id |with id )?not found|could not find (?:the )?(?:conversation|session)|no session with (?:the )?id|unknown session id/i;
|
|
656
|
+
function isSessionNotFoundError(err) {
|
|
657
|
+
if (err == null) return false;
|
|
658
|
+
let text;
|
|
659
|
+
try {
|
|
660
|
+
text = typeof err === "string" ? err : [err.message, err.error?.message, err.subtype, JSON.stringify(err)].filter(Boolean).join(" ");
|
|
661
|
+
} catch {
|
|
662
|
+
text = String(err.message ?? "");
|
|
663
|
+
}
|
|
664
|
+
return SESSION_NOT_FOUND_RE.test(text);
|
|
665
|
+
}
|
|
666
|
+
var TRANSIENT_RE = (
|
|
667
|
+
// `ede_diagnostic` is the CLI's internal "stream ended in an impossible state"
|
|
668
|
+
// marker (e.g. result_type=user stop_reason=tool_use — the turn died mid-tool).
|
|
669
|
+
// It recurs sporadically under load and a bounded replay resumes the turn; left
|
|
670
|
+
// fatal it stranded tasks in error for a hiccup.
|
|
671
|
+
/socket connection.*closed|closed unexpectedly|ECONNRESET|ETIMEDOUT|EPIPE|socket hang ?up|fetch failed|network error|premature close|ede_diagnostic/i
|
|
672
|
+
);
|
|
673
|
+
function isTransientConnectionError(err) {
|
|
674
|
+
if (err == null) return false;
|
|
675
|
+
const status = err.status ?? err.statusCode;
|
|
676
|
+
if (status === 502 || status === 503 || status === 504) return true;
|
|
677
|
+
let text;
|
|
678
|
+
try {
|
|
679
|
+
text = typeof err === "string" ? err : [err.message, err.error?.message, err.code, JSON.stringify(err)].filter(Boolean).join(" ");
|
|
680
|
+
} catch {
|
|
681
|
+
text = String(err.message ?? "");
|
|
682
|
+
}
|
|
683
|
+
return TRANSIENT_RE.test(text);
|
|
684
|
+
}
|
|
685
|
+
function classifyAgentError(err, opts = {}) {
|
|
686
|
+
if (isContextOverflowError(err)) return "overflow";
|
|
687
|
+
if (opts.trustRateLimitText && isRateLimitError(err) || opts.pendingLimit) return "throttle";
|
|
688
|
+
if (isSessionNotFoundError(err)) return "stale_session";
|
|
689
|
+
if (isTransientConnectionError(err)) return "transient";
|
|
690
|
+
return "fatal";
|
|
691
|
+
}
|
|
692
|
+
var AgentSession = class _AgentSession {
|
|
693
|
+
constructor(cfg) {
|
|
694
|
+
this.cfg = cfg;
|
|
695
|
+
this.sessionId = cfg.resumeSessionId;
|
|
696
|
+
this.autoAllow = /* @__PURE__ */ new Set([...cfg.autoAllowTools ?? DEFAULT_AUTO_ALLOW]);
|
|
697
|
+
}
|
|
698
|
+
q;
|
|
699
|
+
input;
|
|
700
|
+
loop;
|
|
701
|
+
sessionId;
|
|
702
|
+
needsRestart = false;
|
|
703
|
+
/** Transient-connection retries for the CURRENT turn (reset on a fresh send). Bounds
|
|
704
|
+
* auto-recovery from dropped sockets so a persistent outage can't replay forever. */
|
|
705
|
+
transientRetries = 0;
|
|
706
|
+
/** Last assistant text block of the in-flight turn. The CLI sometimes reports a
|
|
707
|
+
* dropped socket by PRINTING it as assistant text + an `is_error` result rather
|
|
708
|
+
* than throwing — we inspect this at turn end to retry such a stall. */
|
|
709
|
+
lastAssistantText = "";
|
|
710
|
+
autoAllow;
|
|
711
|
+
/** Text of the in-flight turn, kept so a throttled turn can be replayed on retry. */
|
|
712
|
+
lastTurnText;
|
|
713
|
+
/** Set while a turn is held after a rate-limit, awaiting a coordinated retry. */
|
|
714
|
+
throttled = false;
|
|
715
|
+
/** A deliberate interrupt() is in flight — the next result's error subtype is
|
|
716
|
+
* bookkeeping (our own teardown), not a failure. Cleared on the next send. */
|
|
717
|
+
interrupting = false;
|
|
718
|
+
/** How many times the current turn has been throttled (reset on a fresh send). */
|
|
719
|
+
throttleAttempt = 0;
|
|
720
|
+
/**
|
|
721
|
+
* Captured from the SDK's structured `rate_limit_event` (status "rejected"): a
|
|
722
|
+
* known usage-cap reset window. When set at turn-end the throttle carries it so
|
|
723
|
+
* the orchestrator waits until the reset instead of using adaptive backoff.
|
|
724
|
+
*/
|
|
725
|
+
pendingLimit;
|
|
726
|
+
/**
|
|
727
|
+
* Set once a context-overflow ("Prompt is too long") has triggered an automatic
|
|
728
|
+
* compaction retry for the CURRENT turn. Bounds recovery to a single attempt so
|
|
729
|
+
* an over-limit turn that compaction can't shrink enough fails terminally instead
|
|
730
|
+
* of looping. Reset on a fresh user `send` (a new turn starts clean).
|
|
731
|
+
*/
|
|
732
|
+
compactRecoveryTried = false;
|
|
733
|
+
/**
|
|
734
|
+
* Set once a stale/invalid resume target ("No conversation found") has triggered
|
|
735
|
+
* a fresh-session retry for the CURRENT turn. Bounds recovery to a single attempt
|
|
736
|
+
* so a turn that still can't run after dropping the resume id fails terminally
|
|
737
|
+
* instead of looping. Reset on a fresh user `send`.
|
|
738
|
+
*/
|
|
739
|
+
staleSessionRecoveryTried = false;
|
|
740
|
+
/**
|
|
741
|
+
* Set once a persistent context-overflow (compaction already tried) has triggered
|
|
742
|
+
* a fresh-session retry for the CURRENT turn. Bounds the last-resort recovery to a
|
|
743
|
+
* single attempt — a turn that overflows even a fresh session (a gigantic prompt)
|
|
744
|
+
* fails terminally instead of looping. Reset on a fresh user `send`.
|
|
745
|
+
*/
|
|
746
|
+
overflowFreshSessionTried = false;
|
|
747
|
+
/**
|
|
748
|
+
* Once a session has overflowed, auto-compaction is forced on for every later
|
|
749
|
+
* (re)built query so it self-manages the window from then on (see buildOptions).
|
|
750
|
+
* Never reset within a session.
|
|
751
|
+
*/
|
|
752
|
+
autoCompactForced = false;
|
|
753
|
+
get currentSessionId() {
|
|
754
|
+
return this.sessionId;
|
|
755
|
+
}
|
|
756
|
+
/** Send a user turn, (re)creating the underlying query if needed. */
|
|
757
|
+
async send(text) {
|
|
758
|
+
this.lastTurnText = text;
|
|
759
|
+
this.throttled = false;
|
|
760
|
+
this.throttleAttempt = 0;
|
|
761
|
+
this.transientRetries = 0;
|
|
762
|
+
this.lastAssistantText = "";
|
|
763
|
+
this.pendingLimit = void 0;
|
|
764
|
+
this.compactRecoveryTried = false;
|
|
765
|
+
this.staleSessionRecoveryTried = false;
|
|
766
|
+
this.overflowFreshSessionTried = false;
|
|
767
|
+
await this.pushTurn(text);
|
|
768
|
+
}
|
|
769
|
+
/** Whether a turn is currently held awaiting a coordinated retry. */
|
|
770
|
+
get isThrottled() {
|
|
771
|
+
return this.throttled;
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Replay the throttled turn after the central orchestrator grants a retry.
|
|
775
|
+
* No-op if nothing is held (e.g. the user already sent a new turn).
|
|
776
|
+
*/
|
|
777
|
+
async retry() {
|
|
778
|
+
if (!this.throttled || this.lastTurnText === void 0) return;
|
|
779
|
+
this.throttled = false;
|
|
780
|
+
await this.pushTurn(this.lastTurnText);
|
|
781
|
+
}
|
|
782
|
+
async pushTurn(text) {
|
|
783
|
+
if (this.needsRestart) await this.teardownQuery();
|
|
784
|
+
if (!this.q) {
|
|
785
|
+
this.startQuery();
|
|
786
|
+
} else {
|
|
787
|
+
this.cfg.onEvent({ type: "status", state: "running" });
|
|
788
|
+
}
|
|
789
|
+
this.interrupting = false;
|
|
790
|
+
this.input.push(userMessage(text));
|
|
791
|
+
}
|
|
792
|
+
async interrupt() {
|
|
793
|
+
if (this.throttled) {
|
|
794
|
+
this.throttled = false;
|
|
795
|
+
this.cfg.onEvent({ type: "status", state: "idle" });
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
this.interrupting = true;
|
|
799
|
+
try {
|
|
800
|
+
await this.q?.interrupt();
|
|
801
|
+
} catch (err) {
|
|
802
|
+
this.cfg.onEvent({ type: "error", message: `interrupt failed: ${String(err)}` });
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
async setModel(model) {
|
|
806
|
+
this.cfg.model = model;
|
|
807
|
+
if (this.q) await this.q.setModel(model).catch(() => {
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
/** Emit the current session's slash commands (best-effort; inert on failure). */
|
|
811
|
+
async emitSlashCommands() {
|
|
812
|
+
try {
|
|
813
|
+
const list = await this.q?.supportedCommands?.();
|
|
814
|
+
if (list) this.cfg.onEvent({ type: "slash_commands", commands: mapSlashCommands(list) });
|
|
815
|
+
} catch {
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
async setPermissionMode(mode) {
|
|
819
|
+
this.cfg.permissionMode = mode;
|
|
820
|
+
if (this.q) await this.q.setPermissionMode(mode).catch(() => {
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
/** Effort is a query-construction option; apply it on the next turn. */
|
|
824
|
+
setEffort(effort) {
|
|
825
|
+
this.cfg.effort = effort;
|
|
826
|
+
this.needsRestart = true;
|
|
827
|
+
}
|
|
828
|
+
/** Update unattended-mode (read live by canUseTool) when the phase changes. */
|
|
829
|
+
setAutonomous(autonomous) {
|
|
830
|
+
this.cfg.autonomous = autonomous;
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Swap the Claude Code OAuth token used for auth (account switch on limit).
|
|
834
|
+
* Auth is read at query construction, so this takes effect on the next turn;
|
|
835
|
+
* force a restart so a turn stalled on a limit re-authenticates cleanly.
|
|
836
|
+
*/
|
|
837
|
+
setAuthToken(token) {
|
|
838
|
+
this.cfg.env = { ...this.cfg.env ?? {}, CLAUDE_CODE_OAUTH_TOKEN: token };
|
|
839
|
+
this.needsRestart = true;
|
|
840
|
+
}
|
|
841
|
+
async stop() {
|
|
842
|
+
await this.teardownQuery();
|
|
843
|
+
}
|
|
844
|
+
async teardownQuery() {
|
|
845
|
+
this.input?.end();
|
|
846
|
+
if (this.loop) await this.loop.catch(() => {
|
|
847
|
+
});
|
|
848
|
+
this.q = void 0;
|
|
849
|
+
this.input = void 0;
|
|
850
|
+
this.loop = void 0;
|
|
851
|
+
this.needsRestart = false;
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Rewind the conversation to a prior user message (a checkpoint): restore the
|
|
855
|
+
* worktree files to that point (if file checkpointing was on for this session),
|
|
856
|
+
* fork the transcript truncated at it, and resume from the fork so the next turn
|
|
857
|
+
* continues with the earlier context. Emits a `rewound` divider + idle.
|
|
858
|
+
*/
|
|
859
|
+
async rewind(toUuid, preview) {
|
|
860
|
+
try {
|
|
861
|
+
await this.q?.rewindFiles(toUuid);
|
|
862
|
+
} catch (err) {
|
|
863
|
+
this.cfg.onEvent({ type: "error", message: `File rewind failed: ${String(err)}` });
|
|
864
|
+
}
|
|
865
|
+
let forkedId;
|
|
866
|
+
try {
|
|
867
|
+
const res = await forkSession(this.sessionId ?? "", { upToMessageId: toUuid, dir: this.cfg.cwd });
|
|
868
|
+
forkedId = res.sessionId;
|
|
869
|
+
} catch (err) {
|
|
870
|
+
this.cfg.onEvent({ type: "error", message: `Rewind failed: ${String(err)}` });
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
await this.teardownQuery();
|
|
874
|
+
this.sessionId = forkedId;
|
|
875
|
+
this.cfg.onSessionId?.(forkedId);
|
|
876
|
+
this.cfg.onEvent({ type: "rewound", toUuid, ...preview ? { preview } : {} });
|
|
877
|
+
this.cfg.onEvent({ type: "status", state: "idle" });
|
|
878
|
+
}
|
|
879
|
+
buildOptions() {
|
|
880
|
+
return {
|
|
881
|
+
cwd: this.cfg.cwd,
|
|
882
|
+
resume: this.sessionId,
|
|
883
|
+
enableFileCheckpointing: true,
|
|
884
|
+
model: this.cfg.model,
|
|
885
|
+
effort: this.cfg.effort,
|
|
886
|
+
permissionMode: this.cfg.permissionMode ?? "default",
|
|
887
|
+
mcpServers: this.cfg.mcpServers,
|
|
888
|
+
canUseTool: this.canUseTool,
|
|
889
|
+
onUserDialog: this.onUserDialog,
|
|
890
|
+
includePartialMessages: false,
|
|
891
|
+
// settingSources: when passthrough is off (default), skip the user tier so
|
|
892
|
+
// the agent doesn't inherit ~/.claude settings (hooks, model overrides, etc.).
|
|
893
|
+
// When on, load all three tiers so the agent mirrors the user's interactive env.
|
|
894
|
+
settingSources: this.cfg.passthroughUserSettings ? ["user", "project", "local"] : ["project", "local"],
|
|
895
|
+
// When passthrough is off, force autoCompact on so agents self-manage their
|
|
896
|
+
// context window without relying on the user's setting. Also force it on after
|
|
897
|
+
// a context overflow (autoCompactForced) regardless of the passthrough mode.
|
|
898
|
+
...this.autoCompactForced || !this.cfg.passthroughUserSettings ? { settings: { autoCompactEnabled: true } } : {},
|
|
899
|
+
// Plugins are also loaded explicitly so the agent picks them up regardless of
|
|
900
|
+
// whether the operator's settings enable them.
|
|
901
|
+
...this.cfg.plugins?.length ? { plugins: this.cfg.plugins } : {},
|
|
902
|
+
// BASH_INLINE_ENV defaults win over process.env (builds must run inline even
|
|
903
|
+
// if the user's shell sets a low Bash timeout) but the per-task injected env
|
|
904
|
+
// still overrides, so an explicit per-task value stays authoritative.
|
|
905
|
+
env: { ...process.env, ...BASH_INLINE_ENV, ...this.cfg.env ?? {} },
|
|
906
|
+
systemPrompt: {
|
|
907
|
+
type: "preset",
|
|
908
|
+
preset: "claude_code",
|
|
909
|
+
...this.cfg.appendSystemPrompt ? { append: this.cfg.appendSystemPrompt } : {}
|
|
910
|
+
},
|
|
911
|
+
stderr: () => {
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* React to a context-window overflow ("Prompt is too long"). NOT routed through
|
|
917
|
+
* throttle/RetryOrchestrator — that machinery blindly replays the same turn,
|
|
918
|
+
* which for an over-limit prompt just fails again forever. Instead, bounded to a
|
|
919
|
+
* SINGLE attempt per turn: the first overflow forces auto-compaction on, tears
|
|
920
|
+
* down the dead query, and replays the turn so the rebuilt+resumed session
|
|
921
|
+
* compacts the transcript before re-sending. A second overflow on the same turn
|
|
922
|
+
* means compaction couldn't free enough room → surface a terminal error, no loop.
|
|
923
|
+
*/
|
|
924
|
+
/**
|
|
925
|
+
* Replay the current turn after a transient connection blip (dropped socket, reset,
|
|
926
|
+
* fetch failure), bounded to a few attempts with a short backoff. The agent runs in a
|
|
927
|
+
* detached runner, so the setTimeout survives a server reload. After the cap, surface
|
|
928
|
+
* a terminal error rather than replaying forever against a real outage.
|
|
929
|
+
*/
|
|
930
|
+
/**
|
|
931
|
+
* Replay the current turn, but ONLY if there is one. A recovery can fire on a
|
|
932
|
+
* session reconnected this lifetime with no prior `send` — then `lastTurnText` is
|
|
933
|
+
* empty, and pushing it replays an EMPTY turn, which makes the agent idle asking
|
|
934
|
+
* "anything needed?" (a confusing pseudo-stall that looked like it needed input).
|
|
935
|
+
* With nothing to replay, go idle so the harness re-engages with a real phase prompt.
|
|
936
|
+
*/
|
|
937
|
+
replayCurrentTurn(label) {
|
|
938
|
+
if (!this.lastTurnText) {
|
|
939
|
+
this.cfg.onEvent({ type: "status", state: "idle" });
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
void this.pushTurn(this.lastTurnText).catch((e) => {
|
|
943
|
+
this.cfg.onEvent({ type: "error", message: `${label} failed: ${String(e)}` });
|
|
944
|
+
this.cfg.onEvent({ type: "status", state: "error" });
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
static MAX_TRANSIENT_RETRIES = 3;
|
|
948
|
+
recoverFromTransient(err) {
|
|
949
|
+
if (this.transientRetries >= _AgentSession.MAX_TRANSIENT_RETRIES) {
|
|
950
|
+
this.cfg.onEvent({
|
|
951
|
+
type: "error",
|
|
952
|
+
message: `Connection error (${String(err)}) \u2014 gave up after ${this.transientRetries} retries. Send a new message to retry.`
|
|
953
|
+
});
|
|
954
|
+
this.cfg.onEvent({ type: "status", state: "error" });
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
this.transientRetries++;
|
|
958
|
+
this.needsRestart = true;
|
|
959
|
+
this.cfg.onEvent({ type: "status", state: "running" });
|
|
960
|
+
const delayMs = 1e3 * this.transientRetries;
|
|
961
|
+
setTimeout(() => this.replayCurrentTurn("Connection retry"), delayMs);
|
|
962
|
+
}
|
|
963
|
+
recoverFromOverflow(err) {
|
|
964
|
+
if (!this.compactRecoveryTried) {
|
|
965
|
+
this.compactRecoveryTried = true;
|
|
966
|
+
this.autoCompactForced = true;
|
|
967
|
+
this.needsRestart = true;
|
|
968
|
+
this.cfg.onEvent({ type: "status", state: "running" });
|
|
969
|
+
this.replayCurrentTurn("Compaction retry");
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
if (!this.overflowFreshSessionTried) {
|
|
973
|
+
this.overflowFreshSessionTried = true;
|
|
974
|
+
const stale = this.sessionId;
|
|
975
|
+
this.sessionId = void 0;
|
|
976
|
+
this.needsRestart = true;
|
|
977
|
+
console.error(`[runtime] context overflow persisted after compaction \u2014 dropping session ${stale ?? "(none)"} and replaying in a fresh session`);
|
|
978
|
+
this.cfg.onEvent({
|
|
979
|
+
type: "error",
|
|
980
|
+
message: `Context window exceeded (${String(err)}) and compaction could not recover it. Restarting in a fresh session and replaying the current phase prompt \u2014 earlier in-session context is dropped.`
|
|
981
|
+
});
|
|
982
|
+
this.cfg.onEvent({ type: "status", state: "running" });
|
|
983
|
+
this.replayCurrentTurn("Fresh-session overflow retry");
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
this.cfg.onEvent({
|
|
987
|
+
type: "error",
|
|
988
|
+
message: `Context window exceeded (${String(err)}). Neither compaction nor a fresh session recovered it \u2014 reduce the task's scope or its prompt; retrying as-is will keep failing.`
|
|
989
|
+
});
|
|
990
|
+
this.cfg.onEvent({ type: "status", state: "error" });
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* React to a stale/invalid resume target ("No conversation found with session
|
|
994
|
+
* ID"). The persisted id is dead, so resuming it can only re-fail — and left
|
|
995
|
+
* unhandled it silently bricks the project orchestrator (every grooming/move/
|
|
996
|
+
* start turn dies here). Bounded to a SINGLE attempt per turn: drop the dead id,
|
|
997
|
+
* tear down the query, and replay the turn so a FRESH session is created. The
|
|
998
|
+
* fresh session's `init` persists a new id via onSessionId, auto-correcting the
|
|
999
|
+
* stored stale id (board.md for the orchestrator, frontmatter for a task). A
|
|
1000
|
+
* second session-not-found on the same turn — not expected once resume is
|
|
1001
|
+
* cleared, but guarded — surfaces a terminal error rather than looping.
|
|
1002
|
+
*/
|
|
1003
|
+
recoverFromStaleSession(err) {
|
|
1004
|
+
if (this.staleSessionRecoveryTried) {
|
|
1005
|
+
this.cfg.onEvent({
|
|
1006
|
+
type: "error",
|
|
1007
|
+
message: `Could not resume the session (${String(err)}), and starting a fresh one also failed. The session is unrecoverable \u2014 send a new message to start over.`
|
|
1008
|
+
});
|
|
1009
|
+
this.cfg.onEvent({ type: "status", state: "error" });
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
this.staleSessionRecoveryTried = true;
|
|
1013
|
+
const stale = this.sessionId;
|
|
1014
|
+
this.sessionId = void 0;
|
|
1015
|
+
this.needsRestart = true;
|
|
1016
|
+
console.error(`[runtime] resume target ${stale ?? "(none)"} is gone \u2014 starting a fresh session`);
|
|
1017
|
+
this.cfg.onEvent({ type: "status", state: "running" });
|
|
1018
|
+
this.replayCurrentTurn("Fresh-session retry");
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Park the current turn after a rate-limit/overload instead of failing it. The
|
|
1022
|
+
* dead query is flagged for rebuild (needsRestart) so retry() starts a fresh
|
|
1023
|
+
* one and resumes the session. Emits a `throttled` event for the central
|
|
1024
|
+
* orchestrator plus a `throttled` run-state for the card badge.
|
|
1025
|
+
*/
|
|
1026
|
+
throttle(err) {
|
|
1027
|
+
if (this.throttled) return;
|
|
1028
|
+
this.throttled = true;
|
|
1029
|
+
this.throttleAttempt += 1;
|
|
1030
|
+
this.needsRestart = true;
|
|
1031
|
+
const limit = this.pendingLimit;
|
|
1032
|
+
this.pendingLimit = void 0;
|
|
1033
|
+
this.cfg.onEvent({
|
|
1034
|
+
type: "throttled",
|
|
1035
|
+
attempt: this.throttleAttempt,
|
|
1036
|
+
lastError: String(err),
|
|
1037
|
+
...limit?.resetsAt ? { resetsAt: limit.resetsAt } : {},
|
|
1038
|
+
...limit?.limitType ? { limitType: limit.limitType } : {}
|
|
1039
|
+
});
|
|
1040
|
+
this.cfg.onEvent({ type: "status", state: "throttled" });
|
|
1041
|
+
}
|
|
1042
|
+
startQuery() {
|
|
1043
|
+
this.input = new InputQueue();
|
|
1044
|
+
this.q = query({ prompt: this.input, options: this.buildOptions() });
|
|
1045
|
+
const q = this.q;
|
|
1046
|
+
this.loop = (async () => {
|
|
1047
|
+
try {
|
|
1048
|
+
for await (const msg of q) await this.handle(msg);
|
|
1049
|
+
} catch (err) {
|
|
1050
|
+
switch (classifyAgentError(err, { pendingLimit: !!this.pendingLimit, trustRateLimitText: true })) {
|
|
1051
|
+
case "overflow":
|
|
1052
|
+
this.recoverFromOverflow(err);
|
|
1053
|
+
break;
|
|
1054
|
+
case "throttle":
|
|
1055
|
+
this.throttle(err);
|
|
1056
|
+
break;
|
|
1057
|
+
case "stale_session":
|
|
1058
|
+
this.recoverFromStaleSession(err);
|
|
1059
|
+
break;
|
|
1060
|
+
case "transient":
|
|
1061
|
+
this.recoverFromTransient(err);
|
|
1062
|
+
break;
|
|
1063
|
+
case "fatal":
|
|
1064
|
+
this.cfg.onEvent({ type: "error", message: String(err) });
|
|
1065
|
+
this.cfg.onEvent({ type: "status", state: "error" });
|
|
1066
|
+
break;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
})();
|
|
1070
|
+
}
|
|
1071
|
+
canUseTool = async (toolName, input) => {
|
|
1072
|
+
if (toolName === "AskUserQuestion" && this.cfg.onAskUserQuestion) {
|
|
1073
|
+
const inp = input ?? {};
|
|
1074
|
+
const questions = normalizeQuestions(Array.isArray(inp.questions) ? inp.questions : []);
|
|
1075
|
+
if (questions.length > 0) {
|
|
1076
|
+
const id2 = nanoid(10);
|
|
1077
|
+
this.cfg.onEvent({ type: "ask_user_question", id: id2, questions });
|
|
1078
|
+
const answers = await this.cfg.onAskUserQuestion({ id: id2, questions });
|
|
1079
|
+
if (!answers) return { behavior: "deny", message: "User dismissed the question." };
|
|
1080
|
+
return { behavior: "allow", updatedInput: { ...inp, answers } };
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
const denial = evaluateToolPolicy(toolName, input ?? {}, this.cfg.cwd, {
|
|
1084
|
+
autonomous: !!this.cfg.autonomous
|
|
1085
|
+
});
|
|
1086
|
+
if (denial) {
|
|
1087
|
+
this.cfg.onEvent({ type: "error", message: denial.log });
|
|
1088
|
+
return { behavior: "deny", message: denial.message };
|
|
1089
|
+
}
|
|
1090
|
+
if (this.cfg.autonomous) {
|
|
1091
|
+
return { behavior: "allow", updatedInput: input };
|
|
1092
|
+
}
|
|
1093
|
+
if (this.autoAllow.has(toolName) || toolName.startsWith("mcp__operator__")) {
|
|
1094
|
+
return { behavior: "allow", updatedInput: input };
|
|
1095
|
+
}
|
|
1096
|
+
const id = nanoid(10);
|
|
1097
|
+
this.cfg.onEvent({ type: "permission_request", id, toolName, input });
|
|
1098
|
+
const decision = await this.cfg.onPermissionRequest({ id, toolName, input });
|
|
1099
|
+
if (decision.allow) return { behavior: "allow", updatedInput: input };
|
|
1100
|
+
return { behavior: "deny", message: decision.message ?? "Denied by user" };
|
|
1101
|
+
};
|
|
1102
|
+
/**
|
|
1103
|
+
* The CLI renders blocking dialogs by asking the host (us) to handle them. The
|
|
1104
|
+
* only one we surface is `AskUserQuestion` (dialogKind `permission_ask_user_question`):
|
|
1105
|
+
* we emit an `ask_user_question` event, wait for the user, then return the answers
|
|
1106
|
+
* merged into the tool input. Without this handler the SDK auto-cancels the dialog
|
|
1107
|
+
* and the CLI falls back to "the user did not answer" — i.e. the tool is skipped.
|
|
1108
|
+
* Unrecognized kinds MUST be answered `cancelled` (per the SDK contract).
|
|
1109
|
+
*/
|
|
1110
|
+
onUserDialog = async (request) => {
|
|
1111
|
+
if (!this.cfg.onAskUserQuestion) return { behavior: "cancelled" };
|
|
1112
|
+
const payload = request.payload ?? {};
|
|
1113
|
+
const rawQuestions = findQuestionsArray(payload) ?? findQuestionsArray(request);
|
|
1114
|
+
const questions = normalizeQuestions(rawQuestions);
|
|
1115
|
+
if (questions.length === 0) {
|
|
1116
|
+
if (request.dialogKind === "permission_ask_user_question") {
|
|
1117
|
+
this.cfg.onEvent({
|
|
1118
|
+
type: "error",
|
|
1119
|
+
message: `Could not read the agent's question (dialog "${request.dialogKind}") \u2014 it was skipped.`
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
return { behavior: "cancelled" };
|
|
1123
|
+
}
|
|
1124
|
+
const id = nanoid(10);
|
|
1125
|
+
this.cfg.onEvent({ type: "ask_user_question", id, questions });
|
|
1126
|
+
const answers = await this.cfg.onAskUserQuestion({ id, questions });
|
|
1127
|
+
if (!answers) {
|
|
1128
|
+
return { behavior: "completed", result: { behavior: "deny", message: "User dismissed the question." } };
|
|
1129
|
+
}
|
|
1130
|
+
const input = payload.input && typeof payload.input === "object" ? payload.input : {};
|
|
1131
|
+
return { behavior: "completed", result: { behavior: "allow", updatedInput: { ...input, answers } } };
|
|
1132
|
+
};
|
|
1133
|
+
async handle(msg) {
|
|
1134
|
+
const emit2 = this.cfg.onEvent;
|
|
1135
|
+
switch (msg.type) {
|
|
1136
|
+
case "system": {
|
|
1137
|
+
if (msg.subtype === "init") {
|
|
1138
|
+
this.sessionId = msg.session_id;
|
|
1139
|
+
this.cfg.onSessionId?.(msg.session_id);
|
|
1140
|
+
emit2({ type: "session_init", sessionId: msg.session_id });
|
|
1141
|
+
emit2({ type: "status", state: "running" });
|
|
1142
|
+
void this.emitSlashCommands();
|
|
1143
|
+
}
|
|
1144
|
+
if (msg.subtype === "commands_changed") {
|
|
1145
|
+
emit2({ type: "slash_commands", commands: mapSlashCommands(msg.commands) });
|
|
1146
|
+
}
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
case "assistant": {
|
|
1150
|
+
const content = msg.message?.content;
|
|
1151
|
+
if (!Array.isArray(content)) return;
|
|
1152
|
+
for (const block of content) {
|
|
1153
|
+
if (block.type === "text" && block.text) {
|
|
1154
|
+
this.lastAssistantText = block.text;
|
|
1155
|
+
emit2({ type: "assistant_text", text: block.text });
|
|
1156
|
+
} else if (block.type === "thinking" && block.thinking) {
|
|
1157
|
+
emit2({ type: "thinking", text: block.thinking });
|
|
1158
|
+
} else if (block.type === "tool_use") {
|
|
1159
|
+
if (block.name === "TodoWrite") {
|
|
1160
|
+
const items = (block.input?.todos ?? []).map((t) => ({
|
|
1161
|
+
content: t.content ?? t.activeForm ?? "",
|
|
1162
|
+
status: t.status ?? "pending"
|
|
1163
|
+
}));
|
|
1164
|
+
emit2({ type: "todos", items });
|
|
1165
|
+
} else {
|
|
1166
|
+
emit2({ type: "tool_use", id: block.id, name: block.name, input: block.input });
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
case "user": {
|
|
1173
|
+
const content = msg.message?.content;
|
|
1174
|
+
if (!Array.isArray(content)) return;
|
|
1175
|
+
for (const block of content) {
|
|
1176
|
+
if (block.type === "tool_result") {
|
|
1177
|
+
const images = await this.collectImages(block.content);
|
|
1178
|
+
emit2({
|
|
1179
|
+
type: "tool_result",
|
|
1180
|
+
id: block.tool_use_id,
|
|
1181
|
+
isError: block.is_error,
|
|
1182
|
+
summary: summarize(block.content),
|
|
1183
|
+
...images.length ? { images } : {}
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
case "rate_limit_event": {
|
|
1190
|
+
const info = msg.rate_limit_info;
|
|
1191
|
+
if (info?.status) {
|
|
1192
|
+
emit2({
|
|
1193
|
+
type: "rate_limit",
|
|
1194
|
+
limit: {
|
|
1195
|
+
status: info.status,
|
|
1196
|
+
utilization: info.utilization,
|
|
1197
|
+
rateLimitType: info.rateLimitType,
|
|
1198
|
+
resetsAt: info.resetsAt
|
|
1199
|
+
}
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
this.pendingLimit = info?.status === "rejected" ? { resetsAt: normalizeResetsAt(info.resetsAt), limitType: info.rateLimitType } : void 0;
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
case "result": {
|
|
1206
|
+
const m = msg;
|
|
1207
|
+
emit2({
|
|
1208
|
+
type: "usage",
|
|
1209
|
+
inputTokens: m.usage?.input_tokens,
|
|
1210
|
+
outputTokens: m.usage?.output_tokens,
|
|
1211
|
+
cacheReadTokens: m.usage?.cache_read_input_tokens,
|
|
1212
|
+
cacheCreationTokens: m.usage?.cache_creation_input_tokens
|
|
1213
|
+
});
|
|
1214
|
+
emit2({
|
|
1215
|
+
type: "result",
|
|
1216
|
+
subtype: m.subtype,
|
|
1217
|
+
isError: !!m.is_error,
|
|
1218
|
+
...this.interrupting ? { deliberate: true } : {}
|
|
1219
|
+
});
|
|
1220
|
+
this.interrupting = false;
|
|
1221
|
+
if (m.is_error) {
|
|
1222
|
+
const ctx = [m.subtype, m.result, this.lastAssistantText].filter(Boolean).join(" ");
|
|
1223
|
+
switch (classifyAgentError(ctx, { pendingLimit: !!this.pendingLimit, trustRateLimitText: false })) {
|
|
1224
|
+
case "overflow":
|
|
1225
|
+
this.pendingLimit = void 0;
|
|
1226
|
+
this.recoverFromOverflow(new Error(this.lastAssistantText || "Prompt is too long"));
|
|
1227
|
+
return;
|
|
1228
|
+
case "throttle":
|
|
1229
|
+
this.throttle(new Error(`usage limit${this.pendingLimit?.limitType ? ` (${this.pendingLimit.limitType})` : ""}`));
|
|
1230
|
+
return;
|
|
1231
|
+
case "stale_session":
|
|
1232
|
+
this.recoverFromStaleSession(new Error(`turn ended on a stale session: ${this.lastAssistantText.slice(0, 160)}`));
|
|
1233
|
+
return;
|
|
1234
|
+
case "transient":
|
|
1235
|
+
this.recoverFromTransient(new Error(`turn ended on a connection error: ${this.lastAssistantText.slice(0, 160)}`));
|
|
1236
|
+
return;
|
|
1237
|
+
case "fatal":
|
|
1238
|
+
break;
|
|
1239
|
+
}
|
|
1240
|
+
} else {
|
|
1241
|
+
this.pendingLimit = void 0;
|
|
1242
|
+
}
|
|
1243
|
+
emit2({ type: "status", state: "idle" });
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
default:
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
/** Persist any base64 image blocks in a tool result; returns asset refs. */
|
|
1251
|
+
async collectImages(content) {
|
|
1252
|
+
if (!this.cfg.saveImage) return [];
|
|
1253
|
+
const out = [];
|
|
1254
|
+
for (const { data, mediaType } of base64ImageBlocks(content)) {
|
|
1255
|
+
const file = await this.cfg.saveImage(data, mediaType);
|
|
1256
|
+
if (file) out.push({ file });
|
|
1257
|
+
}
|
|
1258
|
+
return out;
|
|
1259
|
+
}
|
|
1260
|
+
};
|
|
1261
|
+
function mapSlashCommands(raw) {
|
|
1262
|
+
if (!Array.isArray(raw)) return [];
|
|
1263
|
+
const out = [];
|
|
1264
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1265
|
+
for (const c of raw) {
|
|
1266
|
+
if (!c || typeof c.name !== "string" || c.name === "") continue;
|
|
1267
|
+
if (seen.has(c.name)) continue;
|
|
1268
|
+
seen.add(c.name);
|
|
1269
|
+
out.push({
|
|
1270
|
+
name: c.name,
|
|
1271
|
+
description: typeof c.description === "string" ? c.description : "",
|
|
1272
|
+
...typeof c.argumentHint === "string" && c.argumentHint ? { argumentHint: c.argumentHint } : {},
|
|
1273
|
+
...Array.isArray(c.aliases) ? { aliases: c.aliases.filter((a) => typeof a === "string") } : {}
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
return out;
|
|
1277
|
+
}
|
|
1278
|
+
var QUESTION_TEXT_KEYS = ["question", "prompt", "text", "title"];
|
|
1279
|
+
var OPTION_LABEL_KEYS = ["label", "value", "text", "title", "name"];
|
|
1280
|
+
function firstString(o, keys) {
|
|
1281
|
+
for (const k of keys) {
|
|
1282
|
+
const v = o[k];
|
|
1283
|
+
if (typeof v === "string" && v) return v;
|
|
1284
|
+
}
|
|
1285
|
+
return void 0;
|
|
1286
|
+
}
|
|
1287
|
+
function normalizeQuestions(raw) {
|
|
1288
|
+
if (!Array.isArray(raw)) return [];
|
|
1289
|
+
const out = [];
|
|
1290
|
+
for (const q of raw) {
|
|
1291
|
+
if (!q || typeof q !== "object") continue;
|
|
1292
|
+
const text = firstString(q, QUESTION_TEXT_KEYS);
|
|
1293
|
+
if (!text) continue;
|
|
1294
|
+
const rawOptions = q.options ?? q.choices;
|
|
1295
|
+
const options = Array.isArray(rawOptions) ? rawOptions.map((o) => {
|
|
1296
|
+
if (typeof o === "string") return { label: o, description: "" };
|
|
1297
|
+
if (o && typeof o === "object") {
|
|
1298
|
+
const label = firstString(o, OPTION_LABEL_KEYS);
|
|
1299
|
+
return label ? { label, description: typeof o.description === "string" ? o.description : "" } : null;
|
|
1300
|
+
}
|
|
1301
|
+
return null;
|
|
1302
|
+
}).filter((o) => !!o) : [];
|
|
1303
|
+
out.push({
|
|
1304
|
+
question: text,
|
|
1305
|
+
header: typeof q.header === "string" ? q.header : "",
|
|
1306
|
+
multiSelect: q.multiSelect === true || q.allowMultiple === true,
|
|
1307
|
+
options
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
return out;
|
|
1311
|
+
}
|
|
1312
|
+
function findQuestionsArray(value, depth = 0) {
|
|
1313
|
+
if (depth > 5 || !value || typeof value !== "object") return null;
|
|
1314
|
+
if (Array.isArray(value)) {
|
|
1315
|
+
const looksLikeQuestions = value.length > 0 && value.every(
|
|
1316
|
+
(it) => it && typeof it === "object" && firstString(it, QUESTION_TEXT_KEYS)
|
|
1317
|
+
);
|
|
1318
|
+
if (looksLikeQuestions) return value;
|
|
1319
|
+
for (const it of value) {
|
|
1320
|
+
const found = findQuestionsArray(it, depth + 1);
|
|
1321
|
+
if (found) return found;
|
|
1322
|
+
}
|
|
1323
|
+
return null;
|
|
1324
|
+
}
|
|
1325
|
+
const obj = value;
|
|
1326
|
+
if (Array.isArray(obj.questions)) {
|
|
1327
|
+
const found = findQuestionsArray(obj.questions, depth + 1);
|
|
1328
|
+
if (found) return found;
|
|
1329
|
+
}
|
|
1330
|
+
for (const v of Object.values(obj)) {
|
|
1331
|
+
const found = findQuestionsArray(v, depth + 1);
|
|
1332
|
+
if (found) return found;
|
|
1333
|
+
}
|
|
1334
|
+
return null;
|
|
1335
|
+
}
|
|
1336
|
+
function base64ImageBlocks(content) {
|
|
1337
|
+
if (!Array.isArray(content)) return [];
|
|
1338
|
+
const out = [];
|
|
1339
|
+
for (const block of content) {
|
|
1340
|
+
if (block?.type !== "image" || block.source?.type !== "base64") continue;
|
|
1341
|
+
if (typeof block.source.data !== "string" || !block.source.data) continue;
|
|
1342
|
+
out.push({ data: block.source.data, mediaType: block.source.media_type ?? "image/png" });
|
|
1343
|
+
}
|
|
1344
|
+
return out;
|
|
1345
|
+
}
|
|
1346
|
+
function normalizeResetsAt(v) {
|
|
1347
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) return void 0;
|
|
1348
|
+
return v < 1e12 ? Math.round(v * 1e3) : Math.round(v);
|
|
1349
|
+
}
|
|
1350
|
+
function summarize(content) {
|
|
1351
|
+
if (typeof content === "string") return content.slice(0, 240);
|
|
1352
|
+
if (Array.isArray(content)) {
|
|
1353
|
+
return content.map((b) => typeof b === "string" ? b : b?.text ?? "").join(" ").slice(0, 240);
|
|
1354
|
+
}
|
|
1355
|
+
return "";
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// apps/server/src/mcp/operatorServer.ts
|
|
1359
|
+
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
1360
|
+
import { z as z2 } from "zod";
|
|
1361
|
+
|
|
1362
|
+
// apps/server/src/prompts/templates.ts
|
|
1363
|
+
var TITLE_GUIDANCE = 'Titles are 2\u20135 words max \u2014 a glanceable label, not a sentence (e.g. "Persist agent status", "Shorten task titles"). Drop filler and articles; keep every what/why/how in the summary/body, never the title.';
|
|
1364
|
+
var SIZE_GUIDANCE = "Estimate the task's scope as a size: 'xs' a trivial string/copy/constant tweak or one-line fix; 's' a small, localized change; 'm' a normal feature; 'l' a large, multi-part or architecturally significant change; 'xl' a gigantic, architecturally-fundamental change. Be honest \u2014 undersizing skips needed review/testing, oversizing wastes tokens.";
|
|
1365
|
+
var ORCH_SYSTEM_TEMPLATE = `You are the project orchestrator for "<%= projectName %>" in Operator, a kanban-style agent manager. You help the user manage this project's task board and answer questions about its codebase. Tools: create_task, update_task, list_tasks, move_task (advance a task into a lane, which starts that phase). ${TITLE_GUIDANCE} A task's body is structured FIELDS \u2014 set \`problem\`, \`context\`, \`acceptance\` (and \`clarification\` to append) via the tools; you never write the assembled body by hand. When asked to refine a raw todo, populate those fields faithfully without inventing scope. Keep chat answers concise.`;
|
|
1366
|
+
var GROOM_TEMPLATE = `<%= context %>
|
|
1367
|
+
|
|
1368
|
+
Groom it fully: call update_task("<%= taskId %>", ...) with a short title, a one-line summary, the \`problem\` / \`context\` / \`acceptance\` fields (each free markdown \u2014 storage renders the body from them; you never write the body yourself), and a size. ${TITLE_GUIDANCE} ${SIZE_GUIDANCE} Be faithful to the user's intent; do not invent scope (a raw dictated paragraph still gets the full structure \u2014 dedupe repeated phrases). <%= directive %> Just call the tools; no chat reply needed.`;
|
|
1369
|
+
var ROUTE_TEMPLATE = `<%= context %>
|
|
1370
|
+
|
|
1371
|
+
Before treating this as a new task, decide whether the idea actually belongs to an existing one. Call list_tasks and consider ONLY backlog tasks (status "backlog") \u2014 never fold into a task already in progress. For any whose summary looks related, call get_task to read its full body.
|
|
1372
|
+
|
|
1373
|
+
Every idea MUST end as a definite task operation \u2014 refine, fold, or delete. Never leave the placeholder half-groomed (no title/size) or as a raw idea; the board must never hold a task in an in-between state. Choose exactly one:
|
|
1374
|
+
- FOLD \u2014 the idea DUPLICATES or refines/expands one or more backlog tasks (it can be several): for each, call update_task(that id, clarification: "<just the new requirement this idea adds>") \u2014 clarification is appended under the ## Clarifications field, so pass ONLY the added requirement (the rest of the task is left untouched). Then call fold_task("<%= taskId %>", [the task id(s) you expanded]) to dissolve this placeholder into them.
|
|
1375
|
+
- REFINE \u2014 it is genuinely NEW: groom the placeholder in place \u2014 call update_task("<%= taskId %>", ...) with a short title, a one-line summary, the \`problem\` / \`context\` / \`acceptance\` fields (storage renders the body from them), and a size. <%= directive %>
|
|
1376
|
+
- DELETE \u2014 the idea shouldn't exist as a task at all (it's a no-op, noise, or asks to discard it): call delete_task("<%= taskId %>"). Removal is a legitimate grooming outcome.
|
|
1377
|
+
|
|
1378
|
+
The placeholder you were given is id <%= taskId %> \u2014 never match it against itself. ${TITLE_GUIDANCE} ${SIZE_GUIDANCE} Be faithful to the user's intent; do not invent scope (a raw dictated paragraph still gets the full structure \u2014 dedupe repeated phrases). When genuinely unsure whether it's a duplicate, prefer refining it into a new task over folding. Just call the tools; no chat reply needed.`;
|
|
1379
|
+
var TURN_PROMPTS = {
|
|
1380
|
+
/** Vars: <%= feedback %>. Resolves a parked propose_advance when the user answered
|
|
1381
|
+
* the proposal with a MESSAGE instead of accepting — the agent's blocked turn
|
|
1382
|
+
* continues with the feedback in hand (no empty "not approved" + extra turn). */
|
|
1383
|
+
advanceFeedback: `The user did not accept this advance and responded instead:
|
|
1384
|
+
|
|
1385
|
+
<user-note>
|
|
1386
|
+
<%= feedback %>
|
|
1387
|
+
</user-note>
|
|
1388
|
+
|
|
1389
|
+
Address their message, then call mcp__operator__propose_advance again when the phase is truly ready.`,
|
|
1390
|
+
/** Vars: <%= trunk %>, <%= error %>. Sent as a TURN so an idle agent re-engages. */
|
|
1391
|
+
mergeConflictReengage: `The harness squash-merge of your branch into <%= trunk %> hit a conflict:
|
|
1392
|
+
<%= error %>
|
|
1393
|
+
|
|
1394
|
+
Integrate <%= trunk %> into your branch now (\`git merge <%= trunk %>\`), resolve the conflicts, commit, confirm typecheck + tests pass, then call mcp__operator__propose_advance again to re-queue the merge. Don't stop until you've re-proposed (or call request_clarification if a conflict is genuinely ambiguous).`,
|
|
1395
|
+
/** Vars: <%= trunk %>, <%= error %>. Durable fallback when the runner is unreachable. */
|
|
1396
|
+
mergeConflictError: `Merge into <%= trunk %> failed: <%= error %>. Integrate <%= trunk %> into the branch and advance again.`,
|
|
1397
|
+
/** Vars: <%= trunk %>. A corrupt queue entry was dropped — re-engage the card. */
|
|
1398
|
+
lostMergeReengage: `The harness lost your queued merge into <%= trunk %> (the queue entry was corrupted) \u2014 nothing was merged. Re-confirm your branch is ready (typecheck + tests pass), then call mcp__operator__propose_advance again to re-queue the merge.`,
|
|
1399
|
+
/** Vars: <%= trunk %>. Durable fallback when the runner is unreachable. */
|
|
1400
|
+
lostMergeError: `A queued merge into <%= trunk %> was lost (corrupt queue entry). The card is still in ci \u2014 advance again to re-queue.`,
|
|
1401
|
+
/** No vars. Frames a forked session's first message; the user's text follows. */
|
|
1402
|
+
forkKickoff: `[Operator] This session was just forked from its parent. You still have the full prior transcript as context, but you are now an independent branch shown in your own tab alongside the original. Call the operator \`rename_session\` tool to give this tab a short descriptive name for what this branch is about. The user's message for this new branch follows.`
|
|
1403
|
+
};
|
|
1404
|
+
var FACTS = `Project **<%= project %>** \xB7 trunk \`<%= trunk %>\`<% if (noRemote) { %> \xB7 local-only repo (no \`origin\` remote \u2014 never fetch/push one)<% } %> \xB7 size **<%= size %>** \xB7 dev speed <%= devSpeed %> \xB7 planned path: <%= plannedPath %>`;
|
|
1405
|
+
var BG_DETACHED = [
|
|
1406
|
+
"**Verification checkout (read-only).** `<%= worktree %>` is a detached checkout of merged trunk `<%= trunk %>` \u2014 run and inspect it, but never commit, branch, merge, or `cd` out (and never run git against another path, least of all the live checkout at `<%= projectPath %>`). A regression needing code changes goes back to implementation via `propose_advance`, not fixed here."
|
|
1407
|
+
].join("\n");
|
|
1408
|
+
var BG_NON_ISOLATED = [
|
|
1409
|
+
"**Live checkout (no isolation).** This repo can't host a worktree yet, so you are working directly in the user's checkout at `<%= projectPath %>` on trunk `<%= trunk %>`. Smallest possible footprint: only this task's changes, and never push, merge, or switch branches."
|
|
1410
|
+
].join("\n");
|
|
1411
|
+
var BG_ISOLATED = [
|
|
1412
|
+
"**Worktree.** Work only in `<%= worktree %>` on branch `<%= branch %>` \u2014 the harness refuses writes outside it. Never `cd` out, switch branches, or touch trunk `<%= trunk %>` / the live checkout at `<%= projectPath %>` (reads are fine; mutations never).",
|
|
1413
|
+
"Before every commit, confirm `git branch --show-current` prints `<%= branch %>` \u2014 anything else: stop and call `request_clarification`."
|
|
1414
|
+
].join("\n");
|
|
1415
|
+
var branchGuard = `<% if (detached) { %>${BG_DETACHED}<% } else if (isolated) { %>${BG_ISOLATED}<% } else { %>${BG_NON_ISOLATED}<% } %>`;
|
|
1416
|
+
var isolatedLabel = `<% if (isolated) { %>yes<% } else { %>no \u2014 running in the project dir<% } %>`;
|
|
1417
|
+
var EXIT_GATE = `<% if (exitGate === "auto") { %>This lane's gate is **auto**: the proposal advances immediately and \`confident\` has no effect \u2014 propose once and move on; no confirmation is coming.<% } else if (exitGate === "ask") { %>This lane's gate is **ask**: the user confirms every exit. Propose, end your turn, and wait \u2014 \`confident\` has no effect here.<% } else if (exitGate === "checkpoint") { %>Set \`confident: true\` when you're certain \u2014 though a task this size still pauses for a human check when leaving this lane.<% } else { %>Set \`confident: true\` when you're certain and the task advances without waiting; leave it false and the user confirms.<% } %>`;
|
|
1418
|
+
var FORCED_NOTE = `<% if (forcedPhase) { %>
|
|
1419
|
+
|
|
1420
|
+
The planned path for this size would skip this phase, but the user moved the card here deliberately \u2014 do this phase's work in full (never skip-advance out of it), and leave \`confident\` false on exit so the user steers what comes next.<% } %>`;
|
|
1421
|
+
var VR_TINY = `This is a very small change. Keep verification minimal and proportional \u2014 a full build, type-check, or test run is usually overkill for a change this size; do only the quick check (if any) that gives you genuine confidence, and add no tests or polish beyond it.`;
|
|
1422
|
+
var VR_MEDIUM = `Run a quick, targeted sanity check (a type-check or the most relevant fast test) \u2014 don't over-test.`;
|
|
1423
|
+
var VR_LARGE = `Run the relevant sanity checks (type-check / build / fast tests) proportional to the change.`;
|
|
1424
|
+
var VR_TAIL_TESTS = `A dedicated testing phase follows \u2014 leave deep/end-to-end testing for it; just keep the branch buildable.`;
|
|
1425
|
+
var VR_TAIL_NONE = `No separate testing phase runs for a task this size, so there's no later safety net \u2014 but don't gold-plate either: verify only what this change actually warrants.`;
|
|
1426
|
+
var verification = `<% if (tinyChange) { %>${VR_TINY}<% } else if (mediumChange) { %>${VR_MEDIUM}<% } else { %>${VR_LARGE}<% } %> <% if (testingFollows) { %>${VR_TAIL_TESTS}<% } else { %>${VR_TAIL_NONE}<% } %>`;
|
|
1427
|
+
var INDEPENDENT_TESTER = "You are a **fresh, independent tester**. You did not design or build this change and you have none of its implementation context \u2014 that is deliberate. Judge it the way a user would: confirm it actually does what was **requested**, against the expected result below and nothing else. Do **not** read the task's design notes / work log, the git diff, or the commit messages, and don't reverse-engineer how it was built \u2014 testing to the implementation defeats the point. You don't fix or extend the code here; if something is wrong it goes back to the build agent.";
|
|
1428
|
+
var TEST_BRIEF = "## What you are verifying\n\n**Expected result** \u2014 what the change must do, from the user's point of view:\n\n<expected-result>\n<%= expectedResult %>\n</expected-result>\n\n**Affected areas** \u2014 roughly where it lives, so you know where to look (this is the only hint about the implementation you get):\n\n<affected-areas>\n<%= affectedAreas %>\n</affected-areas>";
|
|
1429
|
+
var UO_MOVE = `## User override \u2014 this phase was (re)started by hand
|
|
1430
|
+
|
|
1431
|
+
The user manually moved this card from **<%= fromLane %>** to **<%= toLane %>**<% if (interrupted) { %>, interrupting <%= interrupted %><% } %>. Their intent overrides any prior plan or in-flight phase work \u2014 run this phase fresh from the task's current state (your session context is still valid history, but this instruction supersedes it).`;
|
|
1432
|
+
var UO_RESTART = `## Phase restarted after a server reload
|
|
1433
|
+
|
|
1434
|
+
This card moved to **<%= toLane %>**, but the server restarted before the phase prompt could be delivered. Start (or restart) this phase now from the task's current state.`;
|
|
1435
|
+
var UO_NOTE = `## The user's instruction for this phase
|
|
1436
|
+
|
|
1437
|
+
<user-note>
|
|
1438
|
+
<%= userNote %>
|
|
1439
|
+
</user-note>`;
|
|
1440
|
+
var userOverride = `<% if (userMoved) { %>
|
|
1441
|
+
|
|
1442
|
+
${UO_MOVE}<% } %><% if (phaseRestarted) { %>
|
|
1443
|
+
|
|
1444
|
+
${UO_RESTART}<% } %><% if (userNote) { %>
|
|
1445
|
+
|
|
1446
|
+
${UO_NOTE}<% } %>`;
|
|
1447
|
+
var STATE = `<% if (filesChanged || lastGreen) { %>
|
|
1448
|
+
|
|
1449
|
+
## Branch state (computed)
|
|
1450
|
+
<% if (filesChanged) { %>Files this branch has already changed vs \`<%= trunk %>\` (\`git diff --name-status\` \u2014 current; no need to re-derive it):
|
|
1451
|
+
<files-changed>
|
|
1452
|
+
<%= filesChanged %>
|
|
1453
|
+
</files-changed><% } %><% if (filesChanged && lastGreen) { %>
|
|
1454
|
+
|
|
1455
|
+
<% } %><% if (lastGreen) { %>Already green on this exact worktree content (cached verdict \u2014 re-running proves nothing new): <%= lastGreen %>.<% } %><% } %>`;
|
|
1456
|
+
var taskDoc = `<% if (body) { %>
|
|
1457
|
+
|
|
1458
|
+
The task's full card \u2014 groomed fields plus the work log earlier phases recorded (\`update_task_doc\` appends to it; no need to locate or read the file yourself):
|
|
1459
|
+
<task-document>
|
|
1460
|
+
<%= body %>
|
|
1461
|
+
</task-document><% } %>`;
|
|
1462
|
+
var PHASE_PROMPTS = {
|
|
1463
|
+
"discovery.md": `You are the **Discovery** agent for an Operator task.
|
|
1464
|
+
${FACTS}
|
|
1465
|
+
|
|
1466
|
+
## Task
|
|
1467
|
+
**<%= title %>**
|
|
1468
|
+
|
|
1469
|
+
<task-description>
|
|
1470
|
+
<%= description %>
|
|
1471
|
+
</task-description>${taskDoc}${userOverride}
|
|
1472
|
+
|
|
1473
|
+
## Your job (Discovery)
|
|
1474
|
+
Product discovery \u2014 pin down *what* should be built and how it should feel before anyone designs the implementation: the problem and who it's for, the interaction flow, the states and edge cases a user would hit, and the trade-offs between viable approaches. Deep code research belongs to the Architecture phase \u2014 but a quick peek at the code to **validate a feasibility assumption** is allowed and encouraged: a design resting on an unchecked assumption fails in production.
|
|
1475
|
+
|
|
1476
|
+
If this task is small and already clear, skip discovery: propose the next phase with a one-line note that it isn't needed.
|
|
1477
|
+
|
|
1478
|
+
Record your output with \`mcp__operator__update_task_doc\` \u2014 clarifying questions, a refined description, interaction sketches (text/ASCII or \`render_mockup\`), and/or approach trade-offs. A product decision you can't make yourself \u2192 \`mcp__operator__ask_form\` (discrete options) or \`mcp__operator__request_clarification\`, then wait.${FORCED_NOTE}
|
|
1479
|
+
|
|
1480
|
+
## Exit
|
|
1481
|
+
When the feature and approach are understood, call \`mcp__operator__propose_advance\` with \`to_lane: "<%= nextLane %>"\` and a one-paragraph scope summary. ${EXIT_GATE}`,
|
|
1482
|
+
"architecture.md": `You are the **Architecture** agent for an Operator task. Working at \`<%= worktree %>\` on branch \`<%= branch %>\` (isolated worktree: ${isolatedLabel}).
|
|
1483
|
+
${FACTS}
|
|
1484
|
+
|
|
1485
|
+
${branchGuard}
|
|
1486
|
+
|
|
1487
|
+
## Task
|
|
1488
|
+
**<%= title %>**
|
|
1489
|
+
|
|
1490
|
+
<task-description>
|
|
1491
|
+
<%= description %>
|
|
1492
|
+
</task-description>${taskDoc}${userOverride}
|
|
1493
|
+
|
|
1494
|
+
## Your job (Architecture)
|
|
1495
|
+
Research the implementation this change touches (earlier findings are in the task card above; prefer reusing existing modules/patterns) and record a concrete **design** with \`mcp__operator__update_task_doc\`: Design, Files to change, Test plan, Risks \u2014 proportional to the size. Genuinely different viable approaches \u2192 \`mcp__operator__ask_form\` / \`mcp__operator__request_clarification\` and let the user pick; otherwise choose sensible defaults and note them.
|
|
1496
|
+
|
|
1497
|
+
Re-estimate scope: now that the design is concrete, the intake size (**<%= size %>**) may be wrong \u2014 if so call \`mcp__operator__set_size\`; it right-sizes every remaining phase (which run, model/effort, review depth).
|
|
1498
|
+
|
|
1499
|
+
Design only \u2014 do not write code in this phase.${FORCED_NOTE}
|
|
1500
|
+
|
|
1501
|
+
## Exit
|
|
1502
|
+
When the design is settled, call \`mcp__operator__propose_advance\` with \`to_lane: "<%= nextLane %>"\` and a one-paragraph rationale. ${EXIT_GATE}`,
|
|
1503
|
+
"implementation.md": `You are the **Implementation** agent for an Operator task. Working at \`<%= worktree %>\` on branch \`<%= branch %>\` (isolated worktree: ${isolatedLabel}).
|
|
1504
|
+
${FACTS}
|
|
1505
|
+
|
|
1506
|
+
${branchGuard}
|
|
1507
|
+
|
|
1508
|
+
## Task
|
|
1509
|
+
**<%= title %>**
|
|
1510
|
+
|
|
1511
|
+
<task-description>
|
|
1512
|
+
<%= description %>
|
|
1513
|
+
</task-description>${taskDoc}${STATE}${userOverride}
|
|
1514
|
+
|
|
1515
|
+
## Your job (Implementation)
|
|
1516
|
+
Implement the task \u2014 follow the design in the card above when one exists. Match the existing code style, naming, and patterns; reuse existing utilities. Build exactly what the task asks \u2014 no extra features, abstractions, or defensive code beyond it.
|
|
1517
|
+
|
|
1518
|
+
Commit to this branch in small, clearly-messaged commits as you go.
|
|
1519
|
+
|
|
1520
|
+
Verification \u2014 scaled to this task (size **<%= size %>**): ${verification}
|
|
1521
|
+
|
|
1522
|
+
## Before advancing
|
|
1523
|
+
- User-visible change (UI / rendered output / runnable surface) \u2192 start a preview with \`mcp__operator__start_run\` (bind \`$OPERATOR_WEB_PORT\` or \`$OPERATOR_DEV_PORT_BASE\`+n; pass \`url\` so the card gets a clickable link). Pure backend/refactor/config \u2192 skip it. A failed \`start_run\` is a note, not a blocker.
|
|
1524
|
+
<% if (testingFollows) { %>- Hand the independent tester its brief with \`mcp__operator__set_test_brief\`: \`expectedResult\` (what the change must DO from the user's point of view \u2014 start from the acceptance criteria, refreshed if scope drifted) and \`affectedAreas\` (which surfaces changed, so the tester knows where to look). The tester is a FRESH session that sees only this brief \u2014 WHAT changed, never HOW, or the independent check is worthless.
|
|
1525
|
+
<% } %>- Record notable decisions or deviations from the design with \`mcp__operator__update_task_doc\`; a real product/architecture ambiguity \u2192 \`mcp__operator__request_clarification\` and wait.${FORCED_NOTE}
|
|
1526
|
+
|
|
1527
|
+
## Exit
|
|
1528
|
+
When the feature is implemented and the branch builds, call \`mcp__operator__propose_advance\` with \`to_lane: "<%= nextLane %>"\`. Rationale optional. ${EXIT_GATE}
|
|
1529
|
+
|
|
1530
|
+
Stay within this task's scope. No PR, no pushing, never modify trunk \u2014 later phases own those.`,
|
|
1531
|
+
"local-testing.md": `You are the **Independent Test** agent for an Operator task. Worktree \`<%= worktree %>\`, branch \`<%= branch %>\` (isolated worktree: ${isolatedLabel}).
|
|
1532
|
+
${FACTS}
|
|
1533
|
+
|
|
1534
|
+
${branchGuard}
|
|
1535
|
+
|
|
1536
|
+
## You are an independent tester
|
|
1537
|
+
${INDEPENDENT_TESTER}
|
|
1538
|
+
|
|
1539
|
+
## Task
|
|
1540
|
+
**<%= title %>**${userOverride}
|
|
1541
|
+
|
|
1542
|
+
${TEST_BRIEF}
|
|
1543
|
+
|
|
1544
|
+
## Your job (Independent Test)
|
|
1545
|
+
From the expected result alone, work out how a user would exercise this change \u2014 then actually do it: run the app / the relevant commands and drive the real behaviour end-to-end, including the obvious edge cases a user would hit. Run the suite via \`mcp__operator__run_tests\` (never a bare \`Bash\` test command \u2014 it's memory-gated, owns the process, and delivers the verdict back to you; an identical command on unchanged content answers instantly from the green cache). Builds/typechecks may use \`Bash\`, foreground only. A green suite is necessary but not sufficient \u2014 judge the user-facing behaviour.
|
|
1546
|
+
|
|
1547
|
+
You verify; you never fix or commit.${FORCED_NOTE}
|
|
1548
|
+
|
|
1549
|
+
## Verdict
|
|
1550
|
+
- **Works as requested** \u2192 \`mcp__operator__propose_advance\` with \`to_lane: "<%= nextLane %>"\` and a one-line note of what you exercised. ${EXIT_GATE}
|
|
1551
|
+
- **Does not work** \u2192 record a concrete bug list with \`mcp__operator__update_task_doc\` (what you did / expected per the brief / actually happened), then \`mcp__operator__propose_advance\` with \`to_lane: "implementation"\` and \`confident: true\` \u2014 the build agent fixes on this branch and you re-test fresh.
|
|
1552
|
+
- **Brief too ambiguous to judge** \u2192 \`mcp__operator__request_clarification\` and wait.`,
|
|
1553
|
+
"pr-review.local.md": `You are the **Review** agent (local review mode) for an Operator task. Worktree \`<%= worktree %>\`, branch \`<%= branch %>\`. This project reviews and merges **locally** \u2014 there is no pull request.
|
|
1554
|
+
${FACTS}
|
|
1555
|
+
|
|
1556
|
+
## Task
|
|
1557
|
+
**<%= title %>**
|
|
1558
|
+
|
|
1559
|
+
<task-description>
|
|
1560
|
+
<%= description %>
|
|
1561
|
+
</task-description>${taskDoc}${STATE}${userOverride}
|
|
1562
|
+
|
|
1563
|
+
## Your job (Local Review)
|
|
1564
|
+
1. Get **<%= reviewers %>** independent review(s) of the branch diff vs \`<%= trunk %>\` by spawning reviewer subagent(s) with the **Agent** tool. With more than one, split focus (correctness/bugs/edge cases vs design/simplicity/reuse); with one, it covers both. Act on their findings \u2014 don't duplicate their reading yourself.
|
|
1565
|
+
2. Fix the real findings; commit to this branch.
|
|
1566
|
+
3. Confirm still green via \`mcp__operator__run_tests\` (an unchanged tree answers instantly from the green cache).
|
|
1567
|
+
|
|
1568
|
+
Summarize findings + resolutions with \`mcp__operator__update_task_doc\`. \`.operator/\` diff churn is board noise, never a review finding.${FORCED_NOTE}
|
|
1569
|
+
|
|
1570
|
+
## Exit
|
|
1571
|
+
When reviewed and green, call \`mcp__operator__propose_advance\` with \`to_lane: "<%= nextLane %>"\` and a short rationale. ${EXIT_GATE}
|
|
1572
|
+
|
|
1573
|
+
No PR and no merging here \u2014 the next phase owns the merge.`,
|
|
1574
|
+
"pr-review.remote.md": `You are the **PR Review** agent for an Operator task. Worktree \`<%= worktree %>\`, branch \`<%= branch %>\`.
|
|
1575
|
+
${FACTS}
|
|
1576
|
+
|
|
1577
|
+
## Task
|
|
1578
|
+
**<%= title %>**
|
|
1579
|
+
|
|
1580
|
+
<task-description>
|
|
1581
|
+
<%= description %>
|
|
1582
|
+
</task-description>${taskDoc}${STATE}${userOverride}
|
|
1583
|
+
|
|
1584
|
+
## Your job (PR Review)
|
|
1585
|
+
1. Get **<%= reviewers %>** independent review(s) of the branch diff vs \`<%= trunk %>\` by spawning reviewer subagent(s) with the **Agent** tool. With more than one, split focus (correctness/bugs/edge cases vs design/simplicity/reuse). Address the real findings; commit fixes to this branch.
|
|
1586
|
+
2. Push the branch and open a PR to \`<%= trunk %>\` with \`gh pr create\` \u2014 clear title, body summarizing the change, design, and test results. Keep \`.operator/\` out of the PR (its churn is board noise).
|
|
1587
|
+
3. Register it with \`mcp__operator__report_pr\` (URL + number).
|
|
1588
|
+
|
|
1589
|
+
Summarize findings + resolutions with \`mcp__operator__update_task_doc\`. \`gh\` missing or unauthenticated \u2192 \`mcp__operator__request_clarification\`.${FORCED_NOTE}
|
|
1590
|
+
|
|
1591
|
+
## Exit
|
|
1592
|
+
When the PR is open and reviews are addressed, call \`mcp__operator__propose_advance\` with \`to_lane: "<%= nextLane %>"\` and a short rationale. ${EXIT_GATE}
|
|
1593
|
+
|
|
1594
|
+
Do not merge in this phase \u2014 merging happens when the card moves forward.`,
|
|
1595
|
+
"ci.local.md": `You are the **Merge** agent (local review mode) for an Operator task. Task branch \`<%= branch %>\`, worktree \`<%= worktree %>\`.
|
|
1596
|
+
${FACTS}
|
|
1597
|
+
|
|
1598
|
+
${branchGuard}
|
|
1599
|
+
|
|
1600
|
+
## Task
|
|
1601
|
+
**<%= title %>**
|
|
1602
|
+
|
|
1603
|
+
<task-description>
|
|
1604
|
+
<%= description %>
|
|
1605
|
+
</task-description>${taskDoc}${STATE}${userOverride}
|
|
1606
|
+
|
|
1607
|
+
## Your job: ready the branch \u2014 the harness merges
|
|
1608
|
+
Operator performs the squash-merge into \`<%= trunk %>\` and moves this card atomically the moment you signal ready. You never touch \`<%= trunk %>\`, run the squash, or commit to trunk yourself.
|
|
1609
|
+
|
|
1610
|
+
1. **Green.** The pre-flight verdict (when Operator ran one) is above this prompt. PASSED \u2192 it stands; don't re-pay the battery. FAILED \u2192 fix exactly what it reports, then re-check via \`mcp__operator__run_tests\` (a gated run \u2014 end your turn and wait for the verdict; unchanged content answers instantly from cache).
|
|
1611
|
+
2. **Integrate trunk into the branch:** \`git merge <%= trunk %>\`. Trunk has moved since you branched, and squash-merges mean it may hold newer versions of the very files you touched:
|
|
1612
|
+
- On every conflict take trunk's side: \`git checkout --theirs -- <path> && git add <path>\` (board noise wholesale: \`git checkout --theirs -- .operator && git add .operator\`).
|
|
1613
|
+
- Then \`git diff <%= trunk %>...HEAD\` and confirm THIS task's change is still present. If trunk's version clobbered it, re-implement it on top of trunk's CURRENT code \u2014 adapt to trunk's refactors; never paste your branch's older copy of a file back over trunk's (that silently reverts sibling features; it destroyed a merged feature once).
|
|
1614
|
+
- Integration genuinely ambiguous \u2192 \`git merge --abort\`, then \`mcp__operator__request_clarification\`.
|
|
1615
|
+
- Commit the integration. A clean merge changes nothing about the pre-flight verdict; conflicts that touched a package \u2192 re-check just that package via \`mcp__operator__run_tests\`.
|
|
1616
|
+
3. **Hand off:** record what you did with \`mcp__operator__update_task_doc\`, then call \`mcp__operator__propose_advance\` with \`to_lane: "<%= nextLane %>"\` \u2014 that is the merge signal. ${EXIT_GATE}
|
|
1617
|
+
|
|
1618
|
+
Never \`git checkout <%= trunk %>\`, squash, push, or remove this worktree \u2014 the harness owns those. If it reports a merge conflict back, repeat step 2 and re-propose.`,
|
|
1619
|
+
"ci.remote.md": `You are the **CI / Merge** agent for an Operator task. Worktree \`<%= worktree %>\`, branch \`<%= branch %>\`. The user moved this task here to merge it and get the pipeline green.
|
|
1620
|
+
${FACTS}
|
|
1621
|
+
|
|
1622
|
+
## Task
|
|
1623
|
+
**<%= title %>**
|
|
1624
|
+
|
|
1625
|
+
<task-description>
|
|
1626
|
+
<%= description %>
|
|
1627
|
+
</task-description>${taskDoc}${STATE}${userOverride}
|
|
1628
|
+
|
|
1629
|
+
## Your job (CI / Merge)
|
|
1630
|
+
1. Merge this task's PR into \`<%= trunk %>\` once mergeable (\`gh pr merge --squash --delete-branch\` or the repo's convention). Only this task's PR \u2014 never unrelated branches, never force-push \`<%= trunk %>\`. The PR should carry no \`.operator/\` changes \u2014 drop them if it does.
|
|
1631
|
+
2. Babysit the pipeline to completion (\`gh pr checks\`, \`gh run watch\`). Failures \u2192 diagnose from the logs and push a fix.
|
|
1632
|
+
3. Record the merge commit + final CI status with \`mcp__operator__update_task_doc\`. Blocked (conflicts, required reviews, checks you can't fix) \u2192 \`mcp__operator__request_clarification\` and wait.${FORCED_NOTE}
|
|
1633
|
+
|
|
1634
|
+
Merging to \`<%= trunk %>\` is outward-facing and hard to reverse \u2014 confirm the PR is the right one before merging, and stop to ask if anything looks off.
|
|
1635
|
+
|
|
1636
|
+
## Exit
|
|
1637
|
+
When merged and CI is green, call \`mcp__operator__propose_advance\` with \`to_lane: "<%= nextLane %>"\` and a short rationale. ${EXIT_GATE}`,
|
|
1638
|
+
"prod-testing.local.md": `You are the **Verify** agent (local review mode) for an Operator task. The change is already merged into trunk \`<%= trunk %>\`; your job is to confirm the *integrated* result works and nothing regressed.
|
|
1639
|
+
${FACTS}
|
|
1640
|
+
|
|
1641
|
+
${branchGuard}
|
|
1642
|
+
|
|
1643
|
+
## You are an independent tester
|
|
1644
|
+
${INDEPENDENT_TESTER}
|
|
1645
|
+
|
|
1646
|
+
## Task
|
|
1647
|
+
**<%= title %>**${userOverride}
|
|
1648
|
+
|
|
1649
|
+
${TEST_BRIEF}
|
|
1650
|
+
|
|
1651
|
+
## Your job (Verify)
|
|
1652
|
+
1. Run the suite via \`mcp__operator__run_tests\` (unchanged content answers instantly from the green cache) and exercise the feature the way a user would \u2014 actually drive the behaviour, don't just read code. If the feature can only be judged by a human (voice, feel, hardware), say so and ask for a manual check via \`mcp__operator__request_clarification\` instead of simulating one.
|
|
1653
|
+
2. **Reproduce-then-confirm (assumption-based bugfixes only).** If this is a bug fix whose root cause was *inferred* rather than proven, a green suite proves nothing \u2014 drive the user's actual failing path: confirm the original symptom reproduces on the **pre-fix** code (scratch checkout at the merge commit's first parent \u2014 never mutate this one), then prove it's **gone** here. Record both observations. Features, refactors, and obvious mechanical fixes skip this.
|
|
1654
|
+
3. Non-destructive only \u2014 never commit, branch, or change code here.
|
|
1655
|
+
|
|
1656
|
+
## Verdict
|
|
1657
|
+
- **Verified, all good** \u2192 \`mcp__operator__propose_advance\` with \`to_lane: "<%= nextLane %>"\`. ${EXIT_GATE}
|
|
1658
|
+
- **Regression needing a code change** \u2192 record the specifics (what broke, how to reproduce, logs) with \`mcp__operator__update_task_doc\`, then \`mcp__operator__propose_advance\` with \`to_lane: "implementation"\` \u2014 Operator reopens a fresh worktree; don't fix it in this detached checkout.
|
|
1659
|
+
- **Unsure or blocked** \u2192 \`mcp__operator__request_clarification\`.`,
|
|
1660
|
+
"prod-testing.remote.md": `You are the **Production Testing** agent for an Operator task. The change has been merged and deployed (or is deploying).
|
|
1661
|
+
${FACTS}
|
|
1662
|
+
|
|
1663
|
+
## You are an independent tester
|
|
1664
|
+
${INDEPENDENT_TESTER}
|
|
1665
|
+
|
|
1666
|
+
## Task
|
|
1667
|
+
**<%= title %>**${userOverride}
|
|
1668
|
+
|
|
1669
|
+
${TEST_BRIEF}
|
|
1670
|
+
|
|
1671
|
+
## Your job (Prod Testing)
|
|
1672
|
+
1. Verify the shipped feature works in the deployed environment and nothing obvious regressed \u2014 non-destructive means only: health endpoints, read-only \`curl\`, smoke checks, or a browser pass against the live URL.
|
|
1673
|
+
2. **Assumption-based bugfixes:** if the fix's cause was *inferred* rather than mechanical, a passing smoke check isn't enough \u2014 drive the user's actual failing path live and confirm the specific original symptom is gone. Features/refactors/mechanical fixes skip this.
|
|
1674
|
+
3. Deployment URL/environment unknown \u2192 \`mcp__operator__request_clarification\`.
|
|
1675
|
+
|
|
1676
|
+
Record what you verified + evidence (status codes, observed behaviour) with \`mcp__operator__update_task_doc\`. A regression \u2192 report it clearly and \`mcp__operator__request_clarification\` rather than guessing.
|
|
1677
|
+
|
|
1678
|
+
Keep everything against production read-only and reversible.
|
|
1679
|
+
|
|
1680
|
+
## Verdict
|
|
1681
|
+
When verified live, call \`mcp__operator__propose_advance\` with \`to_lane: "<%= nextLane %>"\` and a short rationale. ${EXIT_GATE}`
|
|
1682
|
+
};
|
|
1683
|
+
|
|
1684
|
+
// apps/server/src/prompts/render.ts
|
|
1685
|
+
import { Eta } from "eta";
|
|
1686
|
+
var DELIMITER_TAGS = /<\s*\/?\s*(?:user-request|user-note|task-description|task-document)\s*>/gi;
|
|
1687
|
+
var cc = (n) => String.fromCharCode(n);
|
|
1688
|
+
var CONTROL_CHARS = new RegExp(`[${cc(0)}-${cc(8)}${cc(11)}${cc(12)}${cc(14)}-${cc(31)}${cc(127)}]`, "g");
|
|
1689
|
+
function sanitizeBlock(s, max = 8e3) {
|
|
1690
|
+
const out = s.replace(CONTROL_CHARS, "").replace(DELIMITER_TAGS, "").trim();
|
|
1691
|
+
return out.length > max ? `${out.slice(0, max)}
|
|
1692
|
+
\u2026(truncated)` : out;
|
|
1693
|
+
}
|
|
1694
|
+
var eta = new Eta({ autoEscape: false, useWith: true, autoTrim: false });
|
|
1695
|
+
function renderTemplate(tpl, vars) {
|
|
1696
|
+
return eta.renderString(tpl, vars).replace(/^\n+/, "");
|
|
1697
|
+
}
|
|
1698
|
+
var advanceFeedback = (text) => renderTemplate(TURN_PROMPTS.advanceFeedback, { feedback: sanitizeBlock(text, 4e3) });
|
|
1699
|
+
|
|
1700
|
+
// apps/server/src/mcp/operatorServer.ts
|
|
1701
|
+
var ok = (text) => ({ content: [{ type: "text", text }] });
|
|
1702
|
+
function buildOperatorMcp(ctx) {
|
|
1703
|
+
return createSdkMcpServer({
|
|
1704
|
+
name: "operator",
|
|
1705
|
+
version: "0.1.0",
|
|
1706
|
+
// Phase prompts MANDATE these tools (propose_advance, run_tests, …) — without
|
|
1707
|
+
// this every session's first call failed ("No such tool available") and paid a
|
|
1708
|
+
// ToolSearch round-trip to load schemas the prompt already promised existed.
|
|
1709
|
+
alwaysLoad: true,
|
|
1710
|
+
instructions: "Operator task-management callbacks. Use request_clarification to ask the user a question, propose_advance to move this task to the most appropriate lane when a phase is done (see that tool's description), update_task_doc to record findings/spec in the task's document, and attach_artifact to surface mockups or screenshots. Render rich content inline with render_mockup (sandboxed HTML/CSS UI mockups), render_diagram (Mermaid diagrams), and ask_form (an interactive multiple-choice question form with custom-reply support that returns the user's selections).",
|
|
1711
|
+
tools: [
|
|
1712
|
+
tool(
|
|
1713
|
+
"request_clarification",
|
|
1714
|
+
"Ask the user a clarifying question and wait for their answer. Optionally provide choices.",
|
|
1715
|
+
{ question: z2.string(), options: z2.array(z2.string()).optional() },
|
|
1716
|
+
async (args) => {
|
|
1717
|
+
const answer = await ctx.requestClarification(args.question, args.options);
|
|
1718
|
+
return ok(`User answered: ${answer}`);
|
|
1719
|
+
}
|
|
1720
|
+
),
|
|
1721
|
+
tool(
|
|
1722
|
+
"propose_advance",
|
|
1723
|
+
"Call when this phase is done to move the task to the next lane. Pick the right target lane (skip phases this task doesn't need; go back if testing found a design flaw). Set `confident: true` to auto-advance without waiting for the user (fast-track dev-speed only; enterprise and large tasks leaving implementation always pause). Leave it false or omit to let the user confirm. `rationale` is optional \u2014 a short note is fine; skip it entirely if there's nothing non-obvious to say. If the user queued messages while you worked, the advance is BLOCKED and the result tells you so \u2014 address them, then re-propose.",
|
|
1724
|
+
{
|
|
1725
|
+
to_lane: z2.enum(LANE_IDS),
|
|
1726
|
+
rationale: z2.string().optional(),
|
|
1727
|
+
confident: z2.boolean().optional()
|
|
1728
|
+
},
|
|
1729
|
+
async (args) => {
|
|
1730
|
+
const result = await ctx.proposeAdvance(
|
|
1731
|
+
args.to_lane,
|
|
1732
|
+
args.rationale,
|
|
1733
|
+
args.confident ?? false
|
|
1734
|
+
);
|
|
1735
|
+
if (result === true) return ok(`Accepted \u2014 task moved to ${args.to_lane}.`);
|
|
1736
|
+
if (result === false) return ok("User declined the advance.");
|
|
1737
|
+
return ok(result);
|
|
1738
|
+
}
|
|
1739
|
+
),
|
|
1740
|
+
tool(
|
|
1741
|
+
"update_task_doc",
|
|
1742
|
+
"Append markdown (findings, spec, decisions) to this task's detail document.",
|
|
1743
|
+
{ markdown: z2.string() },
|
|
1744
|
+
async (args) => {
|
|
1745
|
+
await ctx.appendDoc(args.markdown);
|
|
1746
|
+
return ok("Task document updated.");
|
|
1747
|
+
}
|
|
1748
|
+
),
|
|
1749
|
+
tool(
|
|
1750
|
+
"set_size",
|
|
1751
|
+
`Re-estimate this task's scope (size) when you understand it better than at intake \u2014 e.g. after architecture. ${SIZE_GUIDANCE} Size right-sizes the remaining phases (which run, the per-step model/effort, review depth), so correcting it saves tokens downstream.`,
|
|
1752
|
+
{ size: z2.enum(TASK_SIZES) },
|
|
1753
|
+
async (args) => {
|
|
1754
|
+
await ctx.setSize(args.size);
|
|
1755
|
+
return ok(`Task size set to ${args.size}.`);
|
|
1756
|
+
}
|
|
1757
|
+
),
|
|
1758
|
+
tool(
|
|
1759
|
+
"set_test_brief",
|
|
1760
|
+
'Record the brief the INDEPENDENT test phase will verify against. The tester runs in a fresh session with NO implementation context \u2014 it sees only this brief, not the design, diff, or work log. Call it at the end of build, before advancing to testing. `expectedResult`: what the feature should DO from the user\'s point of view (the intended outcome / acceptance criteria, refreshed if they drifted during build) \u2014 NO implementation detail. `affectedAreas`: a high-level note of which areas/surfaces changed (e.g. "the task card header and its API route"), so the tester knows where to look \u2014 again, WHAT changed, not HOW. Either field may be omitted to leave it as-is.',
|
|
1761
|
+
{ expectedResult: z2.string().optional(), affectedAreas: z2.string().optional() },
|
|
1762
|
+
async (args) => {
|
|
1763
|
+
await ctx.setTestBrief({ expectedResult: args.expectedResult, affectedAreas: args.affectedAreas });
|
|
1764
|
+
return ok("Test brief updated.");
|
|
1765
|
+
}
|
|
1766
|
+
),
|
|
1767
|
+
tool(
|
|
1768
|
+
"rename_session",
|
|
1769
|
+
"Rename THIS session's tab in the Operator UI. Use it right after a fork: give the forked branch a short, descriptive name (a few words) reflecting what this branch is exploring, so it's distinguishable from its sibling tabs. Only affects forked sessions; a no-op for a task's main session.",
|
|
1770
|
+
{ name: z2.string() },
|
|
1771
|
+
async (args) => {
|
|
1772
|
+
const renamed = await ctx.renameSession(args.name);
|
|
1773
|
+
return ok(
|
|
1774
|
+
renamed ? `Session renamed to "${args.name}".` : "Not renamed \u2014 this is the task's main session (only a forked session's tab can be renamed)."
|
|
1775
|
+
);
|
|
1776
|
+
}
|
|
1777
|
+
),
|
|
1778
|
+
tool(
|
|
1779
|
+
"report_pr",
|
|
1780
|
+
"Register the pull request opened for this task. The URL is shown on the task card.",
|
|
1781
|
+
{ url: z2.string(), number: z2.number().optional() },
|
|
1782
|
+
async (args) => {
|
|
1783
|
+
await ctx.setPr(args.url, args.number);
|
|
1784
|
+
await ctx.appendDoc(`**PR:** ${args.url}`);
|
|
1785
|
+
return ok("PR recorded.");
|
|
1786
|
+
}
|
|
1787
|
+
),
|
|
1788
|
+
tool(
|
|
1789
|
+
"start_run",
|
|
1790
|
+
"Start a long-lived process (dev server, watcher, preview) as a TRACKED run. Prefer this over `Bash` with run_in_background for anything you want the user to see, open, or try: Operator surfaces its status, logs and served URL in the task panel, and automatically stops it when the task reaches Done \u2014 so it never orphans. Bind servers to the per-task ports in the env ($OPERATOR_WEB_PORT / $OPERATOR_PORT). The UI's Open link auto-detects the served URL (the web port, then any URL the process prints); pass `url` to pin it explicitly (e.g. the web UI when the command starts both a UI and a backend). Returns a run id; the command runs in the task's worktree. If the process exits on its own (crash or completion) or fails to start, you will RECEIVE A MESSAGE with its exit status \u2014 no need to poll.",
|
|
1791
|
+
{ name: z2.string(), command: z2.string(), url: z2.string().optional() },
|
|
1792
|
+
async (args) => {
|
|
1793
|
+
const runId = ctx.startRun(args.name, args.command, args.url);
|
|
1794
|
+
return ok(`Started run "${args.name}" (id ${runId}). Tracked in the Operator UI; it will be stopped automatically when the task is done.`);
|
|
1795
|
+
}
|
|
1796
|
+
),
|
|
1797
|
+
tool(
|
|
1798
|
+
"run_tests",
|
|
1799
|
+
"Run a test suite as a memory-GATED run. ALWAYS use this (never a bare `Bash` `pnpm test` / vitest / jest) to execute tests: Operator tracks system memory and QUEUES test runs behind free RAM, so parallel testing tasks wait their turn instead of all running at once and OOM-ing the box. The run may sit `queued` briefly before it starts. When it finishes you will RECEIVE A MESSAGE with the verdict \u2014 PASSED, or FAILED with the failing tests + log tail \u2014 so END YOUR TURN and wait for it (or keep working); never poll. Pass the full test command (e.g. `pnpm -r test`); to scope a run, pass test FILE paths (a `-t` name filter still loads every file). Verdicts are cached: the same command on a worktree whose content hasn't changed answers instantly from the previous green run \u2014 so never re-run a suite just to be sure. Returns a run id.",
|
|
1800
|
+
{ name: z2.string(), command: z2.string() },
|
|
1801
|
+
async (args) => {
|
|
1802
|
+
const runId = ctx.runTests(args.name, args.command);
|
|
1803
|
+
return ok(
|
|
1804
|
+
`Queued test run "${args.name}" (id ${runId}) \u2014 command: ${args.command}
|
|
1805
|
+
It starts when system memory allows; you'll receive the pass/fail verdict as a message (instantly, if this exact command already passed on the current worktree content).`
|
|
1806
|
+
);
|
|
1807
|
+
}
|
|
1808
|
+
),
|
|
1809
|
+
tool(
|
|
1810
|
+
"stop_run",
|
|
1811
|
+
"Request a stop of a tracked run you started earlier with start_run, by its run id. Applied asynchronously; stopping an unknown or already-finished run is a silent no-op (no confirmation message is sent either way).",
|
|
1812
|
+
{ runId: z2.string() },
|
|
1813
|
+
async (args) => {
|
|
1814
|
+
ctx.stopRun(args.runId);
|
|
1815
|
+
return ok(`Stop requested for run ${args.runId} (a no-op if it already finished or the id is unknown).`);
|
|
1816
|
+
}
|
|
1817
|
+
),
|
|
1818
|
+
tool(
|
|
1819
|
+
"attach_artifact",
|
|
1820
|
+
"Surface an artifact (mockup, screenshot, link, proposal) in the task panel.",
|
|
1821
|
+
{
|
|
1822
|
+
kind: z2.string(),
|
|
1823
|
+
title: z2.string(),
|
|
1824
|
+
path: z2.string().optional(),
|
|
1825
|
+
data: z2.unknown().optional()
|
|
1826
|
+
},
|
|
1827
|
+
async (args) => {
|
|
1828
|
+
ctx.emitArtifact(args.kind, args.title, args.path, args.data);
|
|
1829
|
+
await ctx.appendDoc(`**Artifact (${args.kind}):** ${args.title}${args.path ? ` \u2014 \`${args.path}\`` : ""}`);
|
|
1830
|
+
return ok("Artifact attached.");
|
|
1831
|
+
}
|
|
1832
|
+
),
|
|
1833
|
+
tool(
|
|
1834
|
+
"render_mockup",
|
|
1835
|
+
"Render a UI mockup inline in the chat as a visual preview. Supply static HTML (and optional CSS); it is shown in a sandboxed iframe (no scripts, no network) so the user can see the proposed layout. Use for wireframes and UI proposals \u2014 not for runnable code.",
|
|
1836
|
+
{
|
|
1837
|
+
title: z2.string().describe("Short label shown above the mockup."),
|
|
1838
|
+
html: z2.string().describe("The mockup body HTML."),
|
|
1839
|
+
css: z2.string().optional().describe("Optional CSS applied to the mockup.")
|
|
1840
|
+
},
|
|
1841
|
+
async (args) => {
|
|
1842
|
+
ctx.renderMockup(args.title, args.html, args.css);
|
|
1843
|
+
return ok("Mockup rendered.");
|
|
1844
|
+
}
|
|
1845
|
+
),
|
|
1846
|
+
tool(
|
|
1847
|
+
"render_diagram",
|
|
1848
|
+
"Render a Mermaid diagram inline in the chat (flowchart, sequence, ER, etc.). Supply Mermaid source; the user sees the rendered graph.",
|
|
1849
|
+
{
|
|
1850
|
+
source: z2.string().describe("Mermaid diagram source."),
|
|
1851
|
+
title: z2.string().optional().describe("Optional label shown above the diagram.")
|
|
1852
|
+
},
|
|
1853
|
+
async (args) => {
|
|
1854
|
+
ctx.renderDiagram(args.source, args.title);
|
|
1855
|
+
return ok("Diagram rendered.");
|
|
1856
|
+
}
|
|
1857
|
+
),
|
|
1858
|
+
tool(
|
|
1859
|
+
"ask_form",
|
|
1860
|
+
"Pose one or more multiple-choice questions as an interactive form and wait for the user's answers. Each question can be single- or multi-select, and the user can always type a custom 'Other' reply. Returns the chosen answers. Use this over request_clarification when the decision has discrete options.",
|
|
1861
|
+
{
|
|
1862
|
+
questions: z2.array(
|
|
1863
|
+
z2.object({
|
|
1864
|
+
question: z2.string().describe("The full question text."),
|
|
1865
|
+
header: z2.string().describe("Short \u226412-char chip label for the question."),
|
|
1866
|
+
multiSelect: z2.boolean().optional().describe("Allow picking more than one option (default false)."),
|
|
1867
|
+
options: z2.array(
|
|
1868
|
+
z2.object({
|
|
1869
|
+
label: z2.string().min(1),
|
|
1870
|
+
description: z2.string().optional()
|
|
1871
|
+
})
|
|
1872
|
+
).min(1)
|
|
1873
|
+
})
|
|
1874
|
+
).min(1)
|
|
1875
|
+
},
|
|
1876
|
+
async (args) => {
|
|
1877
|
+
const questions = args.questions.map((q) => ({
|
|
1878
|
+
question: q.question,
|
|
1879
|
+
header: q.header,
|
|
1880
|
+
multiSelect: q.multiSelect ?? false,
|
|
1881
|
+
options: q.options.map((o) => ({ label: o.label, description: o.description ?? "" }))
|
|
1882
|
+
}));
|
|
1883
|
+
const answers = await ctx.askForm(questions);
|
|
1884
|
+
if (!answers) return ok("User dismissed the form without answering.");
|
|
1885
|
+
const formatted = Object.entries(answers).map(([q, a]) => `- ${q}: ${a}`).join("\n");
|
|
1886
|
+
return ok(`User submitted:
|
|
1887
|
+
${formatted}`);
|
|
1888
|
+
}
|
|
1889
|
+
),
|
|
1890
|
+
tool(
|
|
1891
|
+
"page_eval",
|
|
1892
|
+
"Run JavaScript in the live preview page (the open browser tab carrying the Operator DevTools overlay) and return the result \u2014 no Playwright needed. The code runs inside an async function, so you can `await`; use `return <value>` to return a result (a returned DOM node comes back as its outerHTML). Use it to inspect or test the running app: read app state, query/measure the DOM, call page functions, check computed styles, etc. Requires the previewed page to be open in a browser; errors if none is connected.",
|
|
1893
|
+
{ code: z2.string().describe("JavaScript to evaluate. `return <value>` to return a result.") },
|
|
1894
|
+
async (args) => {
|
|
1895
|
+
const r = await ctx.pageCommand("eval", { code: args.code });
|
|
1896
|
+
if (!r.ok) return ok(`\u26A0 ${r.error ?? "eval failed"}`);
|
|
1897
|
+
return ok(typeof r.result === "string" ? r.result : JSON.stringify(r.result ?? null, null, 2));
|
|
1898
|
+
}
|
|
1899
|
+
),
|
|
1900
|
+
tool(
|
|
1901
|
+
"page_snapshot",
|
|
1902
|
+
"Return the rendered HTML of the live preview page (or a CSS-selector subtree) so you can read/navigate the actual DOM without Playwright. Long output is truncated.",
|
|
1903
|
+
{ selector: z2.string().optional().describe("CSS selector to snapshot; omit for the whole page.") },
|
|
1904
|
+
async (args) => {
|
|
1905
|
+
const r = await ctx.pageCommand("snapshot", { selector: args.selector });
|
|
1906
|
+
return r.ok ? ok(String(r.result ?? "")) : ok(`\u26A0 ${r.error ?? "snapshot failed"}`);
|
|
1907
|
+
}
|
|
1908
|
+
),
|
|
1909
|
+
tool(
|
|
1910
|
+
"page_click",
|
|
1911
|
+
"Click an element in the live preview page by CSS selector \u2014 simulates a real click to navigate / toggle / submit. No Playwright needed.",
|
|
1912
|
+
{ selector: z2.string() },
|
|
1913
|
+
async (args) => {
|
|
1914
|
+
const r = await ctx.pageCommand("click", { selector: args.selector });
|
|
1915
|
+
if (!r.ok) return ok(`\u26A0 ${r.error ?? "click failed"}`);
|
|
1916
|
+
return ok(typeof r.result === "string" ? r.result : "Clicked.");
|
|
1917
|
+
}
|
|
1918
|
+
),
|
|
1919
|
+
tool(
|
|
1920
|
+
"page_info",
|
|
1921
|
+
"Get the live preview page's URL + title (confirms a preview is connected).",
|
|
1922
|
+
{},
|
|
1923
|
+
async () => {
|
|
1924
|
+
const r = await ctx.pageCommand("info", {});
|
|
1925
|
+
return r.ok ? ok(JSON.stringify(r.result ?? {}, null, 2)) : ok(`\u26A0 ${r.error ?? "no preview connected"}`);
|
|
1926
|
+
}
|
|
1927
|
+
)
|
|
1928
|
+
]
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
// apps/server/src/mcp/orchestratorServer.ts
|
|
1933
|
+
import { createSdkMcpServer as createSdkMcpServer2, tool as tool2 } from "@anthropic-ai/claude-agent-sdk";
|
|
1934
|
+
import { z as z3 } from "zod";
|
|
1935
|
+
var FIELD_PARAMS = {
|
|
1936
|
+
problem: z3.string().optional(),
|
|
1937
|
+
context: z3.string().optional(),
|
|
1938
|
+
acceptance: z3.string().optional(),
|
|
1939
|
+
clarification: z3.string().optional()
|
|
1940
|
+
};
|
|
1941
|
+
function fieldsFromArgs(a) {
|
|
1942
|
+
const f = {};
|
|
1943
|
+
if (a.problem !== void 0) f.problem = a.problem;
|
|
1944
|
+
if (a.context !== void 0) f.context = a.context;
|
|
1945
|
+
if (a.acceptance !== void 0) f.acceptance = a.acceptance;
|
|
1946
|
+
if (a.clarification !== void 0) f.clarifications = a.clarification;
|
|
1947
|
+
return f;
|
|
1948
|
+
}
|
|
1949
|
+
var ok2 = (text) => ({ content: [{ type: "text", text }] });
|
|
1950
|
+
function buildOrchestratorMcp(ctx) {
|
|
1951
|
+
return createSdkMcpServer2({
|
|
1952
|
+
name: "operator",
|
|
1953
|
+
version: "0.1.0",
|
|
1954
|
+
// The orchestrator's whole job is these tools — never defer them behind
|
|
1955
|
+
// ToolSearch (the groom/route nudges mandate specific calls).
|
|
1956
|
+
alwaysLoad: true,
|
|
1957
|
+
instructions: "Project board tools. create_task adds a backlog task; update_task patches a task's fields in place (title/summary/size + problem/context/acceptance/clarification \u2014 you never write the raw body); list_tasks shows the board (with summaries); get_task reads one task's full body; fold_task dissolves an idea's placeholder into existing backlog task(s) (smart idea routing); delete_task removes a task (a valid groom outcome); move_task advances a task to a lane (which runs that phase); start_task begins a backlog task at the first phase its scope needs.",
|
|
1958
|
+
tools: [
|
|
1959
|
+
tool2(
|
|
1960
|
+
"create_task",
|
|
1961
|
+
`Add a new task to the backlog. ${TITLE_GUIDANCE} Also give a one-line summary, and the structured body as separate FIELDS \u2014 \`problem\`, \`context\`, \`acceptance\` (each free markdown). The body is assembled from these fields (## Problem / ## Context / ## Acceptance criteria); never write the assembled body yourself.`,
|
|
1962
|
+
{
|
|
1963
|
+
title: z3.string(),
|
|
1964
|
+
summary: z3.string().optional(),
|
|
1965
|
+
...FIELD_PARAMS
|
|
1966
|
+
},
|
|
1967
|
+
async (args) => {
|
|
1968
|
+
const fields = fieldsFromArgs(args);
|
|
1969
|
+
const id = await ctx.createTask(args.title, args.summary ?? "", fields);
|
|
1970
|
+
return ok2(`Created task ${id}: ${args.title}`);
|
|
1971
|
+
}
|
|
1972
|
+
),
|
|
1973
|
+
tool2(
|
|
1974
|
+
"update_task",
|
|
1975
|
+
`Patch a task in place \u2014 set any subset of its fields (or all of them); unspecified fields are left untouched. Frontmatter: \`title\`, \`summary\`, \`size\`. ${SIZE_GUIDANCE} Size drives which phases the task runs and how diligently (model, reasoning effort, review depth) under the project's dev-speed. ${TITLE_GUIDANCE} Body fields (free markdown each, replacing that section): \`problem\`, \`context\`, \`acceptance\`. To EXPAND a task with a folded-in idea, pass \`clarification\` (the new requirement only) \u2014 it is APPENDED under ## Clarifications, leaving the rest intact. You never write the assembled body; storage renders it from fields.`,
|
|
1976
|
+
{
|
|
1977
|
+
task_id: z3.string(),
|
|
1978
|
+
title: z3.string().optional(),
|
|
1979
|
+
summary: z3.string().optional(),
|
|
1980
|
+
...FIELD_PARAMS,
|
|
1981
|
+
size: z3.enum(TASK_SIZES).optional()
|
|
1982
|
+
},
|
|
1983
|
+
async (args) => {
|
|
1984
|
+
const fields = fieldsFromArgs(args);
|
|
1985
|
+
const okd = await ctx.updateTask(args.task_id, {
|
|
1986
|
+
title: args.title,
|
|
1987
|
+
shortDescription: args.summary,
|
|
1988
|
+
fields: Object.keys(fields).length ? fields : void 0,
|
|
1989
|
+
size: args.size
|
|
1990
|
+
});
|
|
1991
|
+
return ok2(okd ? `Updated task ${args.task_id}.` : `Task ${args.task_id} not found.`);
|
|
1992
|
+
}
|
|
1993
|
+
),
|
|
1994
|
+
tool2(
|
|
1995
|
+
"list_tasks",
|
|
1996
|
+
"List all tasks on the board with their lane and one-line summary (the summary is the matching signal for smart idea routing \u2014 titles alone are too thin).",
|
|
1997
|
+
{},
|
|
1998
|
+
async () => {
|
|
1999
|
+
const tasks = await ctx.listTasks();
|
|
2000
|
+
return ok2(JSON.stringify(tasks));
|
|
2001
|
+
}
|
|
2002
|
+
),
|
|
2003
|
+
tool2(
|
|
2004
|
+
"get_task",
|
|
2005
|
+
"Read one task's full detail \u2014 title, summary, body, size, lane. Use before folding an idea into a backlog task so you can append to (not overwrite) its existing body.",
|
|
2006
|
+
{ task_id: z3.string() },
|
|
2007
|
+
async (args) => {
|
|
2008
|
+
const t = await ctx.getTask(args.task_id);
|
|
2009
|
+
return ok2(t ? JSON.stringify(t) : `Task ${args.task_id} not found.`);
|
|
2010
|
+
}
|
|
2011
|
+
),
|
|
2012
|
+
tool2(
|
|
2013
|
+
"fold_task",
|
|
2014
|
+
"Smart idea routing: dissolve an idea's placeholder task into the existing backlog task(s) you just expanded with update_task. Deletes the placeholder and animates it merging into the target(s). Only call AFTER you have folded the idea into each target via update_task.",
|
|
2015
|
+
{ placeholder_id: z3.string(), into_ids: z3.array(z3.string()).min(1) },
|
|
2016
|
+
async (args) => {
|
|
2017
|
+
const okd = await ctx.foldTask(args.placeholder_id, args.into_ids);
|
|
2018
|
+
return ok2(
|
|
2019
|
+
okd ? `Folded ${args.placeholder_id} into ${args.into_ids.join(", ")}.` : "Fold failed."
|
|
2020
|
+
);
|
|
2021
|
+
}
|
|
2022
|
+
),
|
|
2023
|
+
tool2(
|
|
2024
|
+
"delete_task",
|
|
2025
|
+
"Remove a task from the board. A valid grooming outcome: an idea that shouldn't exist as its own task and isn't being folded into another. Every idea must end as a definite task operation \u2014 refine it, fold it, or delete it; never leave one half-groomed.",
|
|
2026
|
+
{ task_id: z3.string() },
|
|
2027
|
+
async (args) => {
|
|
2028
|
+
const okd = await ctx.deleteTask(args.task_id);
|
|
2029
|
+
return ok2(okd ? `Deleted task ${args.task_id}.` : `Task ${args.task_id} not found.`);
|
|
2030
|
+
}
|
|
2031
|
+
),
|
|
2032
|
+
tool2(
|
|
2033
|
+
"move_task",
|
|
2034
|
+
"Move a task to a lane. Moving into an automated lane starts that phase's agent. The move is applied asynchronously: this returns once the request is queued, and if the move is rejected (e.g. an invalid transition) you will RECEIVE A MESSAGE explaining why \u2014 verify with list_tasks if in doubt.",
|
|
2035
|
+
{ task_id: z3.string(), to_lane: z3.enum(LANE_IDS) },
|
|
2036
|
+
async (args) => {
|
|
2037
|
+
const okd = await ctx.moveTask(args.task_id, args.to_lane);
|
|
2038
|
+
return ok2(
|
|
2039
|
+
okd ? `Move of task ${args.task_id} to ${args.to_lane} requested. If it can't be applied, you'll receive a message explaining why.` : "Move failed."
|
|
2040
|
+
);
|
|
2041
|
+
}
|
|
2042
|
+
),
|
|
2043
|
+
tool2(
|
|
2044
|
+
"start_task",
|
|
2045
|
+
`Start a backlog task now. The board picks the first phase the task's scope (size) requires under the project's dev-speed \u2014 e.g. a small task skips discovery/architecture and begins at implementation. Estimate the size with update_task first so the right phases are chosen. The start is applied asynchronously (like move_task): a rejected start is delivered back to you as a message.`,
|
|
2046
|
+
{ task_id: z3.string() },
|
|
2047
|
+
async (args) => {
|
|
2048
|
+
const okd = await ctx.startTask(args.task_id);
|
|
2049
|
+
return ok2(
|
|
2050
|
+
okd ? `Start of task ${args.task_id} requested. If it can't be applied, you'll receive a message explaining why.` : `Start failed \u2014 task ${args.task_id} not found in backlog (or it has no startable phase).`
|
|
2051
|
+
);
|
|
2052
|
+
}
|
|
2053
|
+
),
|
|
2054
|
+
tool2(
|
|
2055
|
+
"rename_session",
|
|
2056
|
+
"Rename your own tab to a short descriptive name (only takes effect for a side-session fork \u2014 the main project agent's tab is fixed).",
|
|
2057
|
+
{ name: z3.string() },
|
|
2058
|
+
async (args) => {
|
|
2059
|
+
await ctx.renameSession(args.name);
|
|
2060
|
+
return ok2(`Renamed session tab to "${args.name}".`);
|
|
2061
|
+
}
|
|
2062
|
+
)
|
|
2063
|
+
]
|
|
2064
|
+
});
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
// apps/server/src/agent/kickoff.ts
|
|
2068
|
+
function kickoffLane(task, devSpeed) {
|
|
2069
|
+
if (task.status !== "backlog") return null;
|
|
2070
|
+
return firstPlannedLane(devSpeed, taskScopeSize(task));
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
// apps/server/src/storage/eventStore.ts
|
|
2074
|
+
import { promises as fs2 } from "node:fs";
|
|
2075
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
2076
|
+
import { basename, join as join2 } from "node:path";
|
|
2077
|
+
|
|
2078
|
+
// apps/server/src/config.ts
|
|
2079
|
+
import { homedir } from "node:os";
|
|
2080
|
+
import { join } from "node:path";
|
|
2081
|
+
var PORT = Number(process.env.OPERATOR_PORT ?? 4317);
|
|
2082
|
+
var OPERATOR_DIR = ".operator";
|
|
2083
|
+
var WORKTREES_DIR = "worktrees";
|
|
2084
|
+
var OPERATOR_HOME = process.env.OPERATOR_HOME ?? join(homedir(), ".operator");
|
|
2085
|
+
var PROJECTS_REGISTRY = join(OPERATOR_HOME, "projects.json");
|
|
2086
|
+
var GLOBAL_SETTINGS = join(OPERATOR_HOME, "settings.json");
|
|
2087
|
+
var USAGE_HISTORY = join(OPERATOR_HOME, "usage-history.json");
|
|
2088
|
+
function operatorDir(projectPath) {
|
|
2089
|
+
return join(projectPath, OPERATOR_DIR);
|
|
2090
|
+
}
|
|
2091
|
+
function worktreesDir(projectPath) {
|
|
2092
|
+
return join(operatorDir(projectPath), WORKTREES_DIR);
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
// apps/server/src/storage/util.ts
|
|
2096
|
+
import { promises as fs } from "node:fs";
|
|
2097
|
+
import { dirname } from "node:path";
|
|
2098
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2099
|
+
import { customAlphabet } from "nanoid";
|
|
2100
|
+
var newTaskId = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 8);
|
|
2101
|
+
async function atomicWriteFile(file, data, opts = {}) {
|
|
2102
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
2103
|
+
try {
|
|
2104
|
+
const fh = await fs.open(tmp, "w", opts.mode);
|
|
2105
|
+
try {
|
|
2106
|
+
await fh.writeFile(data);
|
|
2107
|
+
await fh.sync();
|
|
2108
|
+
} finally {
|
|
2109
|
+
await fh.close();
|
|
2110
|
+
}
|
|
2111
|
+
await fs.rename(tmp, file);
|
|
2112
|
+
} catch (e) {
|
|
2113
|
+
await fs.rm(tmp, { force: true }).catch(() => {
|
|
2114
|
+
});
|
|
2115
|
+
throw e;
|
|
2116
|
+
}
|
|
2117
|
+
try {
|
|
2118
|
+
const dh = await fs.open(dirname(file), "r");
|
|
2119
|
+
await dh.sync().finally(() => dh.close());
|
|
2120
|
+
} catch {
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
var LockTimeoutError = class extends Error {
|
|
2124
|
+
constructor(file) {
|
|
2125
|
+
super(`Timed out waiting for the write lock on ${file} \u2014 the board is busy; retry.`);
|
|
2126
|
+
this.file = file;
|
|
2127
|
+
this.name = "LockTimeoutError";
|
|
2128
|
+
}
|
|
2129
|
+
statusCode = 503;
|
|
2130
|
+
};
|
|
2131
|
+
async function withFileLock(file, fn, opts = {}) {
|
|
2132
|
+
const lock = `${file}.lock`;
|
|
2133
|
+
const timeoutMs = opts.timeoutMs ?? 5e3;
|
|
2134
|
+
const staleMs = opts.staleMs ?? 3e4;
|
|
2135
|
+
const retryMs = opts.retryMs ?? 25;
|
|
2136
|
+
const deadline = Date.now() + timeoutMs;
|
|
2137
|
+
for (; ; ) {
|
|
2138
|
+
try {
|
|
2139
|
+
await fs.mkdir(lock);
|
|
2140
|
+
break;
|
|
2141
|
+
} catch {
|
|
2142
|
+
try {
|
|
2143
|
+
const st = await fs.stat(lock);
|
|
2144
|
+
if (Date.now() - st.mtimeMs > staleMs) {
|
|
2145
|
+
await fs.rm(lock, { recursive: true, force: true });
|
|
2146
|
+
continue;
|
|
2147
|
+
}
|
|
2148
|
+
} catch {
|
|
2149
|
+
}
|
|
2150
|
+
if (Date.now() > deadline) throw new LockTimeoutError(file);
|
|
2151
|
+
await new Promise((r) => setTimeout(r, retryMs));
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
const beat = setInterval(() => {
|
|
2155
|
+
const now = /* @__PURE__ */ new Date();
|
|
2156
|
+
void fs.utimes(lock, now, now).catch(() => {
|
|
2157
|
+
});
|
|
2158
|
+
}, Math.max(50, Math.floor(staleMs / 3)));
|
|
2159
|
+
beat.unref?.();
|
|
2160
|
+
try {
|
|
2161
|
+
return await fn();
|
|
2162
|
+
} finally {
|
|
2163
|
+
clearInterval(beat);
|
|
2164
|
+
await fs.rm(lock, { recursive: true, force: true }).catch(() => {
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
function chainPerKey(chains, key, fn) {
|
|
2169
|
+
const prev = chains.get(key) ?? Promise.resolve();
|
|
2170
|
+
const run = prev.then(fn, fn);
|
|
2171
|
+
const tail = run.then(
|
|
2172
|
+
() => void 0,
|
|
2173
|
+
() => void 0
|
|
2174
|
+
);
|
|
2175
|
+
chains.set(key, tail);
|
|
2176
|
+
void tail.then(() => {
|
|
2177
|
+
if (chains.get(key) === tail) chains.delete(key);
|
|
2178
|
+
});
|
|
2179
|
+
return run;
|
|
2180
|
+
}
|
|
2181
|
+
function sanitizeKey(key) {
|
|
2182
|
+
return key.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
2183
|
+
}
|
|
2184
|
+
function slugify(text) {
|
|
2185
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "").slice(0, 48) || "task";
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
// apps/server/src/storage/eventStore.ts
|
|
2189
|
+
function eventsDir(projectPath) {
|
|
2190
|
+
return join2(operatorDir(projectPath), ".events");
|
|
2191
|
+
}
|
|
2192
|
+
function assetsDir(projectPath) {
|
|
2193
|
+
return join2(eventsDir(projectPath), "assets");
|
|
2194
|
+
}
|
|
2195
|
+
var EXT_BY_MIME = {
|
|
2196
|
+
"image/png": "png",
|
|
2197
|
+
"image/jpeg": "jpg",
|
|
2198
|
+
"image/gif": "gif",
|
|
2199
|
+
"image/webp": "webp"
|
|
2200
|
+
};
|
|
2201
|
+
async function writeAsset(project2, dataBase64, mediaType) {
|
|
2202
|
+
try {
|
|
2203
|
+
const buf = Buffer.from(dataBase64, "base64");
|
|
2204
|
+
if (!buf.length) return null;
|
|
2205
|
+
const hash = createHash2("sha256").update(buf).digest("hex").slice(0, 16);
|
|
2206
|
+
const ext = EXT_BY_MIME[mediaType] ?? "bin";
|
|
2207
|
+
const file = `${hash}.${ext}`;
|
|
2208
|
+
const dir = assetsDir(project2.path);
|
|
2209
|
+
await fs2.mkdir(dir, { recursive: true });
|
|
2210
|
+
await fs2.writeFile(join2(dir, file), buf, { flag: "wx" }).catch((e) => {
|
|
2211
|
+
if (e?.code !== "EEXIST") throw e;
|
|
2212
|
+
});
|
|
2213
|
+
return file;
|
|
2214
|
+
} catch {
|
|
2215
|
+
return null;
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
function sanitize(key) {
|
|
2219
|
+
return key.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
2220
|
+
}
|
|
2221
|
+
function eventFile(project2, key) {
|
|
2222
|
+
return join2(eventsDir(project2.path), `${sanitize(key)}.jsonl`);
|
|
2223
|
+
}
|
|
2224
|
+
var streamWriteChains = /* @__PURE__ */ new Map();
|
|
2225
|
+
function withStreamQueue(project2, key, fn) {
|
|
2226
|
+
const file = eventFile(project2, key);
|
|
2227
|
+
return chainPerKey(streamWriteChains, file, async () => {
|
|
2228
|
+
await fs2.mkdir(eventsDir(project2.path), { recursive: true });
|
|
2229
|
+
return withFileLock(file, fn);
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
var tailHealed = /* @__PURE__ */ new Set();
|
|
2233
|
+
async function healTornTail(file) {
|
|
2234
|
+
if (tailHealed.has(file)) return;
|
|
2235
|
+
tailHealed.add(file);
|
|
2236
|
+
let fh;
|
|
2237
|
+
try {
|
|
2238
|
+
fh = await fs2.open(file, "r");
|
|
2239
|
+
const { size } = await fh.stat();
|
|
2240
|
+
if (size === 0) return;
|
|
2241
|
+
const buf = Buffer.alloc(1);
|
|
2242
|
+
await fh.read(buf, 0, 1, size - 1);
|
|
2243
|
+
if (buf[0] !== 10) await fs2.appendFile(file, "\n");
|
|
2244
|
+
} catch {
|
|
2245
|
+
} finally {
|
|
2246
|
+
await fh?.close().catch(() => {
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
var rotateAtBytes = Number(process.env.OPERATOR_EVENT_LOG_ROTATE_BYTES ?? 64 * 1024 * 1024);
|
|
2251
|
+
async function appendEvent(project2, key, event) {
|
|
2252
|
+
return withStreamQueue(project2, key, () => appendLocked(project2, key, event)).catch(
|
|
2253
|
+
() => null
|
|
2254
|
+
// a wedged file lock must not break the stream either
|
|
2255
|
+
);
|
|
2256
|
+
}
|
|
2257
|
+
async function appendLocked(project2, key, event) {
|
|
2258
|
+
try {
|
|
2259
|
+
const file = eventFile(project2, key);
|
|
2260
|
+
await healTornTail(file);
|
|
2261
|
+
let seq = await fs2.stat(file).then((s) => s.size, () => 0);
|
|
2262
|
+
if (seq >= rotateAtBytes) {
|
|
2263
|
+
await fs2.rename(file, `${file}.1`);
|
|
2264
|
+
seq = 0;
|
|
2265
|
+
}
|
|
2266
|
+
await fs2.appendFile(file, JSON.stringify(event) + "\n");
|
|
2267
|
+
return seq;
|
|
2268
|
+
} catch {
|
|
2269
|
+
return null;
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
async function truncateEventsToUserMessage(project2, key, ordinal) {
|
|
2273
|
+
if (ordinal < 1) return false;
|
|
2274
|
+
return withStreamQueue(project2, key, () => truncateSerialized(project2, key, ordinal));
|
|
2275
|
+
}
|
|
2276
|
+
async function truncateSerialized(project2, key, ordinal) {
|
|
2277
|
+
try {
|
|
2278
|
+
const file = eventFile(project2, key);
|
|
2279
|
+
const lines = (await fs2.readFile(file, "utf8")).split("\n").filter(Boolean);
|
|
2280
|
+
let seen = 0;
|
|
2281
|
+
let cut = -1;
|
|
2282
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2283
|
+
try {
|
|
2284
|
+
if (JSON.parse(lines[i]).type === "user_message" && ++seen === ordinal) {
|
|
2285
|
+
cut = i;
|
|
2286
|
+
break;
|
|
2287
|
+
}
|
|
2288
|
+
} catch {
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
if (cut < 0) return false;
|
|
2292
|
+
await atomicWriteFile(file, lines.slice(0, cut + 1).join("\n") + "\n");
|
|
2293
|
+
return true;
|
|
2294
|
+
} catch {
|
|
2295
|
+
return false;
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
var READ_TAIL_BYTES = 16 * 1024 * 1024;
|
|
2299
|
+
|
|
2300
|
+
// apps/server/src/storage/actionLog.ts
|
|
2301
|
+
import { promises as fs3 } from "node:fs";
|
|
2302
|
+
import { join as join3 } from "node:path";
|
|
2303
|
+
function actionFile(project2) {
|
|
2304
|
+
return join3(operatorDir(project2.path), ".actions", "log.jsonl");
|
|
2305
|
+
}
|
|
2306
|
+
var actionWriteChains = /* @__PURE__ */ new Map();
|
|
2307
|
+
async function logAction(project2, entry) {
|
|
2308
|
+
const line = JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), ...entry }) + "\n";
|
|
2309
|
+
const file = actionFile(project2);
|
|
2310
|
+
await chainPerKey(actionWriteChains, file, async () => {
|
|
2311
|
+
await fs3.mkdir(join3(operatorDir(project2.path), ".actions"), { recursive: true });
|
|
2312
|
+
await withFileLock(file, () => fs3.appendFile(file, line));
|
|
2313
|
+
}).catch(() => {
|
|
2314
|
+
});
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
// apps/server/src/storage/boardStore.ts
|
|
2318
|
+
import { promises as fs4 } from "node:fs";
|
|
2319
|
+
import { join as join5, basename as basename2 } from "node:path";
|
|
2320
|
+
import matter from "gray-matter";
|
|
2321
|
+
import chokidar from "chokidar";
|
|
2322
|
+
|
|
2323
|
+
// apps/server/src/storage/boardGit.ts
|
|
2324
|
+
import { join as join4, relative } from "node:path";
|
|
2325
|
+
import { simpleGit } from "simple-git";
|
|
2326
|
+
function boardMdFile(project2) {
|
|
2327
|
+
return join4(operatorDir(project2.path), "board.md");
|
|
2328
|
+
}
|
|
2329
|
+
var failureCount = 0;
|
|
2330
|
+
var lastFailure = null;
|
|
2331
|
+
var TRANSIENT = /index\.lock|cannot lock ref|another git process/i;
|
|
2332
|
+
var BENIGN = /not a git repository|MERGE_HEAD|nothing to commit/i;
|
|
2333
|
+
var RETRY_DELAYS_MS = [100, 300, 900];
|
|
2334
|
+
async function commitBoard(project2, paths, summary) {
|
|
2335
|
+
if (paths.length === 0) return;
|
|
2336
|
+
for (let attempt = 0; ; attempt++) {
|
|
2337
|
+
try {
|
|
2338
|
+
await commitBoardOnce(project2, paths, summary);
|
|
2339
|
+
return;
|
|
2340
|
+
} catch (e) {
|
|
2341
|
+
const msg = e?.message ?? String(e);
|
|
2342
|
+
if (BENIGN.test(msg)) return;
|
|
2343
|
+
if (TRANSIENT.test(msg) && attempt < RETRY_DELAYS_MS.length) {
|
|
2344
|
+
await new Promise((r) => setTimeout(r, RETRY_DELAYS_MS[attempt]));
|
|
2345
|
+
continue;
|
|
2346
|
+
}
|
|
2347
|
+
failureCount++;
|
|
2348
|
+
lastFailure = { at: (/* @__PURE__ */ new Date()).toISOString(), project: project2.name, summary, message: msg };
|
|
2349
|
+
console.warn(`[operator] commitBoard failed (${summary}):`, msg);
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
async function commitBoardOnce(project2, paths, summary) {
|
|
2355
|
+
const git = simpleGit(project2.path);
|
|
2356
|
+
const allRels = paths.map((p) => relative(project2.path, p));
|
|
2357
|
+
let rels = allRels;
|
|
2358
|
+
try {
|
|
2359
|
+
const ignored = new Set(
|
|
2360
|
+
(await git.raw(["check-ignore", "--", ...allRels])).split("\n").map((s) => s.trim()).filter(Boolean)
|
|
2361
|
+
);
|
|
2362
|
+
if (ignored.size) rels = allRels.filter((r) => !ignored.has(r));
|
|
2363
|
+
} catch {
|
|
2364
|
+
}
|
|
2365
|
+
if (rels.length === 0) return;
|
|
2366
|
+
await git.raw(["add", "-A", "--", ...rels]);
|
|
2367
|
+
const staged = (await git.raw(["diff", "--cached", "--name-only", "--", ...rels])).trim();
|
|
2368
|
+
if (!staged) return;
|
|
2369
|
+
await git.commit(`chore(operator): ${summary}`, rels, { "--no-verify": null });
|
|
2370
|
+
}
|
|
2371
|
+
async function commitBoardMd(project2, summary) {
|
|
2372
|
+
await commitBoard(project2, [boardMdFile(project2)], `persist project settings (${summary})`);
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
// apps/server/src/storage/boardStore.ts
|
|
2376
|
+
function yamlSafe(data) {
|
|
2377
|
+
const out = {};
|
|
2378
|
+
for (const [k, v] of Object.entries(data)) if (v !== void 0) out[k] = v;
|
|
2379
|
+
return out;
|
|
2380
|
+
}
|
|
2381
|
+
function parseFrontmatter(data) {
|
|
2382
|
+
return TaskFrontmatterSchema.parse(data ?? {});
|
|
2383
|
+
}
|
|
2384
|
+
var taskMutationChains = /* @__PURE__ */ new Map();
|
|
2385
|
+
function withTaskMutex(project2, taskId, fn) {
|
|
2386
|
+
return chainPerKey(taskMutationChains, `${project2.path}\0${taskId}`, fn);
|
|
2387
|
+
}
|
|
2388
|
+
var BUCKETS = ["backlog", "in-progress", "done"];
|
|
2389
|
+
var IN_PROGRESS_BUCKET = "in-progress";
|
|
2390
|
+
var OPERATIONAL_DIRS = [
|
|
2391
|
+
WORKTREES_DIR,
|
|
2392
|
+
".events",
|
|
2393
|
+
".runners",
|
|
2394
|
+
".runs",
|
|
2395
|
+
".composer",
|
|
2396
|
+
".merge-lock",
|
|
2397
|
+
".merge-queue",
|
|
2398
|
+
".actions"
|
|
2399
|
+
];
|
|
2400
|
+
var FALLBACK_IN_PROGRESS_LANE = "implementation";
|
|
2401
|
+
function bucketForLane(lane) {
|
|
2402
|
+
if (lane === "backlog") return "backlog";
|
|
2403
|
+
if (lane === "done") return "done";
|
|
2404
|
+
return IN_PROGRESS_BUCKET;
|
|
2405
|
+
}
|
|
2406
|
+
function bucketDir(project2, bucket) {
|
|
2407
|
+
return join5(operatorDir(project2.path), bucket);
|
|
2408
|
+
}
|
|
2409
|
+
function laneDir(project2, lane) {
|
|
2410
|
+
return bucketDir(project2, bucketForLane(lane));
|
|
2411
|
+
}
|
|
2412
|
+
function asLaneId(v) {
|
|
2413
|
+
return LANE_IDS.includes(v) ? v : null;
|
|
2414
|
+
}
|
|
2415
|
+
function laneForBucketFile(bucket, status) {
|
|
2416
|
+
if (bucket === "backlog") return "backlog";
|
|
2417
|
+
if (bucket === "done") return "done";
|
|
2418
|
+
const st = asLaneId(status);
|
|
2419
|
+
if (st && bucketForLane(st) === IN_PROGRESS_BUCKET) return st;
|
|
2420
|
+
return FALLBACK_IN_PROGRESS_LANE;
|
|
2421
|
+
}
|
|
2422
|
+
async function effectiveLane(file, bucket) {
|
|
2423
|
+
if (bucket === "backlog") return "backlog";
|
|
2424
|
+
if (bucket === "done") return "done";
|
|
2425
|
+
try {
|
|
2426
|
+
return laneForBucketFile(bucket, matter(await fs4.readFile(file, "utf8")).data.status);
|
|
2427
|
+
} catch {
|
|
2428
|
+
return FALLBACK_IN_PROGRESS_LANE;
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
function taskFileName(id, title) {
|
|
2432
|
+
return `${id}-${slugify(title)}.md`;
|
|
2433
|
+
}
|
|
2434
|
+
function idFromFile(file) {
|
|
2435
|
+
const stem = basename2(file).replace(/\.md$/, "");
|
|
2436
|
+
const dash = stem.indexOf("-");
|
|
2437
|
+
return dash === -1 ? stem : stem.slice(0, dash);
|
|
2438
|
+
}
|
|
2439
|
+
function matchesTaskId(name, id) {
|
|
2440
|
+
return name === `${id}.md` || name.startsWith(`${id}-`);
|
|
2441
|
+
}
|
|
2442
|
+
async function listBucketFiles(project2, bucket) {
|
|
2443
|
+
try {
|
|
2444
|
+
const names = await fs4.readdir(bucketDir(project2, bucket));
|
|
2445
|
+
return names.filter((n) => n.endsWith(".md")).map((n) => join5(bucketDir(project2, bucket), n));
|
|
2446
|
+
} catch {
|
|
2447
|
+
return [];
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
async function listLaneFiles(project2, lane) {
|
|
2451
|
+
const bucket = bucketForLane(lane);
|
|
2452
|
+
const files = await listBucketFiles(project2, bucket);
|
|
2453
|
+
if (bucket !== IN_PROGRESS_BUCKET) return files;
|
|
2454
|
+
const out = [];
|
|
2455
|
+
for (const file of files) if (await effectiveLane(file, bucket) === lane) out.push(file);
|
|
2456
|
+
return out;
|
|
2457
|
+
}
|
|
2458
|
+
async function findAllTaskFiles(project2, id) {
|
|
2459
|
+
const out = [];
|
|
2460
|
+
for (const bucket of BUCKETS) {
|
|
2461
|
+
for (const file of await listBucketFiles(project2, bucket)) {
|
|
2462
|
+
if (matchesTaskId(basename2(file), id)) {
|
|
2463
|
+
const st = await fs4.stat(file).catch(() => null);
|
|
2464
|
+
out.push({ lane: await effectiveLane(file, bucket), file, mtimeMs: st?.mtimeMs ?? 0 });
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
return out;
|
|
2469
|
+
}
|
|
2470
|
+
function canonicalTaskFile(all) {
|
|
2471
|
+
return all.reduce((a, b) => {
|
|
2472
|
+
const la = LANE_IDS.indexOf(a.lane);
|
|
2473
|
+
const lb = LANE_IDS.indexOf(b.lane);
|
|
2474
|
+
if (lb !== la) return lb > la ? b : a;
|
|
2475
|
+
if (b.mtimeMs !== a.mtimeMs) return b.mtimeMs > a.mtimeMs ? b : a;
|
|
2476
|
+
return b.file < a.file ? b : a;
|
|
2477
|
+
});
|
|
2478
|
+
}
|
|
2479
|
+
async function findTaskFile(project2, id) {
|
|
2480
|
+
const all = await findAllTaskFiles(project2, id);
|
|
2481
|
+
if (all.length === 0) return null;
|
|
2482
|
+
return canonicalTaskFile(all);
|
|
2483
|
+
}
|
|
2484
|
+
async function ensureSkeleton(project2) {
|
|
2485
|
+
await fs4.mkdir(operatorDir(project2.path), { recursive: true });
|
|
2486
|
+
for (const bucket of BUCKETS) await fs4.mkdir(bucketDir(project2, bucket), { recursive: true });
|
|
2487
|
+
await fs4.mkdir(worktreesDir(project2.path), { recursive: true });
|
|
2488
|
+
const skeletonCommit = [];
|
|
2489
|
+
const boardMd = join5(operatorDir(project2.path), "board.md");
|
|
2490
|
+
if (!await exists(boardMd)) {
|
|
2491
|
+
const fm = matter.stringify(`# ${project2.name}
|
|
2492
|
+
|
|
2493
|
+
Operator board. Drag tasks between lanes to run each phase.
|
|
2494
|
+
`, yamlSafe({
|
|
2495
|
+
name: project2.name,
|
|
2496
|
+
trunk: project2.trunk,
|
|
2497
|
+
reviewMode: project2.reviewMode ?? "local",
|
|
2498
|
+
autoExpand: project2.autoExpand ?? true,
|
|
2499
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2500
|
+
}));
|
|
2501
|
+
await atomicWriteFile(boardMd, fm);
|
|
2502
|
+
skeletonCommit.push(boardMd);
|
|
2503
|
+
}
|
|
2504
|
+
const ignore = join5(operatorDir(project2.path), ".gitignore");
|
|
2505
|
+
const requiredIgnores = [...OPERATIONAL_DIRS.map((d) => `${d}/`), "in-progress/"];
|
|
2506
|
+
let current = "";
|
|
2507
|
+
try {
|
|
2508
|
+
current = await fs4.readFile(ignore, "utf8");
|
|
2509
|
+
} catch {
|
|
2510
|
+
}
|
|
2511
|
+
const present = new Set(current.split("\n").map((l) => l.trim()));
|
|
2512
|
+
const missing = requiredIgnores.filter((line) => !present.has(line));
|
|
2513
|
+
if (missing.length) {
|
|
2514
|
+
const prefix = current === "" || current.endsWith("\n") ? current : `${current}
|
|
2515
|
+
`;
|
|
2516
|
+
await atomicWriteFile(ignore, `${prefix}${missing.join("\n")}
|
|
2517
|
+
`);
|
|
2518
|
+
skeletonCommit.push(ignore);
|
|
2519
|
+
}
|
|
2520
|
+
if (skeletonCommit.length) await commitBoard(project2, skeletonCommit, "init operator skeleton");
|
|
2521
|
+
await migrateFromListFormat(project2);
|
|
2522
|
+
await reconcileBuckets(project2);
|
|
2523
|
+
}
|
|
2524
|
+
async function reconcileBuckets(project2) {
|
|
2525
|
+
const op = operatorDir(project2.path);
|
|
2526
|
+
const tvDir = join5(op, "to-verify");
|
|
2527
|
+
let markers = [];
|
|
2528
|
+
try {
|
|
2529
|
+
markers = await fs4.readdir(tvDir);
|
|
2530
|
+
} catch {
|
|
2531
|
+
}
|
|
2532
|
+
for (const id of markers) {
|
|
2533
|
+
for (const { file } of await findAllTaskFiles(project2, id)) {
|
|
2534
|
+
await withFileLock(file, async () => {
|
|
2535
|
+
const fresh = await readFreshDoc(file);
|
|
2536
|
+
if (!fresh) return;
|
|
2537
|
+
await atomicWriteFile(
|
|
2538
|
+
file,
|
|
2539
|
+
matter.stringify(fresh.body, yamlSafe({ ...fresh.data, status: "prod-testing" }))
|
|
2540
|
+
);
|
|
2541
|
+
});
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
if (markers.length || await exists(tvDir)) await fs4.rm(tvDir, { recursive: true, force: true }).catch(() => {
|
|
2545
|
+
});
|
|
2546
|
+
const legacyDirs = LANE_IDS.filter((l) => bucketForLane(l) === IN_PROGRESS_BUCKET);
|
|
2547
|
+
const sourceDirs = [...BUCKETS, ...legacyDirs];
|
|
2548
|
+
const touched = [];
|
|
2549
|
+
for (const src of sourceDirs) {
|
|
2550
|
+
const dir = join5(op, src);
|
|
2551
|
+
let names;
|
|
2552
|
+
try {
|
|
2553
|
+
names = await fs4.readdir(dir);
|
|
2554
|
+
} catch {
|
|
2555
|
+
continue;
|
|
2556
|
+
}
|
|
2557
|
+
for (const name of names.filter((n) => n.endsWith(".md"))) {
|
|
2558
|
+
const file = join5(dir, name);
|
|
2559
|
+
let status;
|
|
2560
|
+
try {
|
|
2561
|
+
const parsed = matter(await fs4.readFile(file, "utf8"));
|
|
2562
|
+
if (!parsed.data.id) {
|
|
2563
|
+
const idFromName = name.split("-")[0];
|
|
2564
|
+
if (/^[a-z0-9]{6,}$/i.test(idFromName)) {
|
|
2565
|
+
await withFileLock(file, async () => {
|
|
2566
|
+
const fresh = await readFreshDoc(file);
|
|
2567
|
+
if (!fresh || fresh.data.id) return;
|
|
2568
|
+
await atomicWriteFile(
|
|
2569
|
+
file,
|
|
2570
|
+
matter.stringify(fresh.body, yamlSafe({ ...fresh.data, id: idFromName }))
|
|
2571
|
+
);
|
|
2572
|
+
});
|
|
2573
|
+
touched.push(file);
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
status = asLaneId(parsed.data.status) ?? asLaneId(src) ?? FALLBACK_IN_PROGRESS_LANE;
|
|
2577
|
+
} catch {
|
|
2578
|
+
continue;
|
|
2579
|
+
}
|
|
2580
|
+
const target = bucketForLane(status);
|
|
2581
|
+
if (src === target) continue;
|
|
2582
|
+
await withFileLock(file, async () => {
|
|
2583
|
+
const fresh = await readFreshDoc(file);
|
|
2584
|
+
if (!fresh) return;
|
|
2585
|
+
const freshStatus = asLaneId(fresh.data.status) ?? asLaneId(src) ?? FALLBACK_IN_PROGRESS_LANE;
|
|
2586
|
+
const freshTarget = bucketForLane(freshStatus);
|
|
2587
|
+
if (src === freshTarget) return;
|
|
2588
|
+
const destDir = bucketDir(project2, freshTarget);
|
|
2589
|
+
await fs4.mkdir(destDir, { recursive: true });
|
|
2590
|
+
const dest = join5(destDir, name);
|
|
2591
|
+
await fs4.rename(file, dest).catch(() => {
|
|
2592
|
+
});
|
|
2593
|
+
touched.push(file, dest);
|
|
2594
|
+
}).catch(() => {
|
|
2595
|
+
});
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
for (const lane of legacyDirs) await fs4.rm(join5(op, lane), { recursive: true, force: true }).catch(() => {
|
|
2599
|
+
});
|
|
2600
|
+
if (touched.length) await commitBoard(project2, touched, "migrate tasks to lane-decoupled buckets");
|
|
2601
|
+
}
|
|
2602
|
+
var OLD_ITEM_RE = /^- \*\*(.+?)\*\*(?:\s+—\s+(.*?))?\s*(?:\(\[[^\]]*\]\([^)]*\)\))?\s*<!--\s*id:(\w+)\s*-->\s*$/;
|
|
2603
|
+
async function migrateFromListFormat(project2) {
|
|
2604
|
+
const oldLanesDir = join5(operatorDir(project2.path), "lanes");
|
|
2605
|
+
const oldTasksDir = join5(operatorDir(project2.path), "tasks");
|
|
2606
|
+
if (!await exists(oldLanesDir)) return;
|
|
2607
|
+
for (const lane of LANE_IDS) {
|
|
2608
|
+
let text;
|
|
2609
|
+
try {
|
|
2610
|
+
text = await fs4.readFile(join5(oldLanesDir, `${lane}.md`), "utf8");
|
|
2611
|
+
} catch {
|
|
2612
|
+
continue;
|
|
2613
|
+
}
|
|
2614
|
+
let order = 0;
|
|
2615
|
+
for (const line of text.split("\n")) {
|
|
2616
|
+
const m = OLD_ITEM_RE.exec(line.trimEnd());
|
|
2617
|
+
if (!m) continue;
|
|
2618
|
+
const [, title, desc, id] = m;
|
|
2619
|
+
let data = {};
|
|
2620
|
+
let body = "";
|
|
2621
|
+
const oldDoc = await findOldDoc(oldTasksDir, id);
|
|
2622
|
+
if (oldDoc) {
|
|
2623
|
+
const parsed = matter(await fs4.readFile(oldDoc, "utf8"));
|
|
2624
|
+
data = parseFrontmatter(parsed.data);
|
|
2625
|
+
body = parsed.content;
|
|
2626
|
+
}
|
|
2627
|
+
data = {
|
|
2628
|
+
...data,
|
|
2629
|
+
id,
|
|
2630
|
+
title,
|
|
2631
|
+
shortDescription: (desc ?? "").trim() || data.shortDescription || "",
|
|
2632
|
+
status: lane,
|
|
2633
|
+
order: data.order ?? order++,
|
|
2634
|
+
createdAt: data.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
2635
|
+
};
|
|
2636
|
+
if (!body.trim()) body = `# ${title}
|
|
2637
|
+
|
|
2638
|
+
${data.shortDescription ?? ""}
|
|
2639
|
+
`;
|
|
2640
|
+
await atomicWriteFile(
|
|
2641
|
+
join5(laneDir(project2, lane), taskFileName(id, title)),
|
|
2642
|
+
matter.stringify(body, yamlSafe(data))
|
|
2643
|
+
);
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
await fs4.rm(oldLanesDir, { recursive: true, force: true });
|
|
2647
|
+
await fs4.rm(oldTasksDir, { recursive: true, force: true });
|
|
2648
|
+
}
|
|
2649
|
+
async function findOldDoc(oldTasksDir, id) {
|
|
2650
|
+
try {
|
|
2651
|
+
const names = await fs4.readdir(oldTasksDir);
|
|
2652
|
+
const match = names.find((n) => matchesTaskId(n, id));
|
|
2653
|
+
return match ? join5(oldTasksDir, match) : null;
|
|
2654
|
+
} catch {
|
|
2655
|
+
return null;
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
async function readBoardConfig(project2) {
|
|
2659
|
+
let fm;
|
|
2660
|
+
try {
|
|
2661
|
+
fm = BoardFrontmatterSchema.parse(matter(await fs4.readFile(boardMdFile(project2), "utf8")).data ?? {});
|
|
2662
|
+
} catch {
|
|
2663
|
+
fm = BoardFrontmatterSchema.parse({});
|
|
2664
|
+
}
|
|
2665
|
+
return {
|
|
2666
|
+
reviewMode: fm.reviewMode,
|
|
2667
|
+
trunk: fm.trunk,
|
|
2668
|
+
autoExpand: fm.autoExpand,
|
|
2669
|
+
devSpeed: fm.devSpeed,
|
|
2670
|
+
iframeCaching: fm.iframeCaching,
|
|
2671
|
+
orchestratorSessionId: fm.orchestratorSessionId,
|
|
2672
|
+
orchestratorModel: fm.orchestratorModel,
|
|
2673
|
+
orchestratorEffort: fm.orchestratorEffort,
|
|
2674
|
+
orchestratorPermissionMode: fm.orchestratorPermissionMode,
|
|
2675
|
+
orchestratorForks: fm.orchestratorForks,
|
|
2676
|
+
stepGates: { ...DEFAULT_STEP_GATES, ...fm.stepGates },
|
|
2677
|
+
phaseHooks: fm.phaseHooks,
|
|
2678
|
+
devCommand: fm.devCommand && fm.devCommand.trim() ? fm.devCommand : void 0,
|
|
2679
|
+
worktreeSetup: fm.worktreeSetup && fm.worktreeSetup.trim() ? fm.worktreeSetup : void 0
|
|
2680
|
+
};
|
|
2681
|
+
}
|
|
2682
|
+
function applyBoardConfig(project2, config2) {
|
|
2683
|
+
return {
|
|
2684
|
+
...project2,
|
|
2685
|
+
reviewMode: config2.reviewMode,
|
|
2686
|
+
autoExpand: config2.autoExpand,
|
|
2687
|
+
devSpeed: config2.devSpeed,
|
|
2688
|
+
iframeCaching: config2.iframeCaching,
|
|
2689
|
+
stepGates: config2.stepGates,
|
|
2690
|
+
phaseHooks: config2.phaseHooks,
|
|
2691
|
+
worktreeSetup: config2.worktreeSetup,
|
|
2692
|
+
trunk: config2.trunk ?? project2.trunk,
|
|
2693
|
+
orchestratorModel: config2.orchestratorModel,
|
|
2694
|
+
orchestratorEffort: config2.orchestratorEffort,
|
|
2695
|
+
orchestratorPermissionMode: config2.orchestratorPermissionMode,
|
|
2696
|
+
orchestratorForks: config2.orchestratorForks
|
|
2697
|
+
};
|
|
2698
|
+
}
|
|
2699
|
+
var EPHEMERAL_FRONTMATTER = /* @__PURE__ */ new Set(["sessionId", "forks", "pendingKickoff", "kickedLane", "grooming"]);
|
|
2700
|
+
async function setProjectSettings(project2, patch) {
|
|
2701
|
+
const file = boardMdFile(project2);
|
|
2702
|
+
await withFileLock(file, async () => {
|
|
2703
|
+
let parsed;
|
|
2704
|
+
try {
|
|
2705
|
+
parsed = matter(await fs4.readFile(file, "utf8"));
|
|
2706
|
+
} catch {
|
|
2707
|
+
await ensureSkeleton(project2);
|
|
2708
|
+
parsed = matter(await fs4.readFile(file, "utf8"));
|
|
2709
|
+
}
|
|
2710
|
+
const data = { ...parsed.data };
|
|
2711
|
+
if (patch.reviewMode) data.reviewMode = patch.reviewMode;
|
|
2712
|
+
if (patch.trunk) data.trunk = patch.trunk;
|
|
2713
|
+
if (patch.autoExpand !== void 0) data.autoExpand = patch.autoExpand;
|
|
2714
|
+
if (patch.devSpeed) data.devSpeed = patch.devSpeed;
|
|
2715
|
+
if (patch.iframeCaching) data.iframeCaching = patch.iframeCaching;
|
|
2716
|
+
if (patch.orchestratorSessionId) data.orchestratorSessionId = patch.orchestratorSessionId;
|
|
2717
|
+
if (patch.orchestratorModel) data.orchestratorModel = patch.orchestratorModel;
|
|
2718
|
+
if (patch.orchestratorEffort) data.orchestratorEffort = patch.orchestratorEffort;
|
|
2719
|
+
if (patch.orchestratorPermissionMode)
|
|
2720
|
+
data.orchestratorPermissionMode = patch.orchestratorPermissionMode;
|
|
2721
|
+
if (patch.stepGates) {
|
|
2722
|
+
const existing = BoardFrontmatterSchema.parse(data).stepGates;
|
|
2723
|
+
data.stepGates = { ...existing, ...patch.stepGates };
|
|
2724
|
+
}
|
|
2725
|
+
if (patch.phaseHooks) {
|
|
2726
|
+
const merged = { ...BoardFrontmatterSchema.parse(data).phaseHooks };
|
|
2727
|
+
for (const [lane, cmd] of Object.entries(patch.phaseHooks)) {
|
|
2728
|
+
if (cmd && cmd.trim()) merged[lane] = cmd.trim();
|
|
2729
|
+
else delete merged[lane];
|
|
2730
|
+
}
|
|
2731
|
+
data.phaseHooks = merged;
|
|
2732
|
+
}
|
|
2733
|
+
if (patch.worktreeSetup !== void 0) {
|
|
2734
|
+
if (patch.worktreeSetup.trim()) data.worktreeSetup = patch.worktreeSetup.trim();
|
|
2735
|
+
else delete data.worktreeSetup;
|
|
2736
|
+
}
|
|
2737
|
+
await atomicWriteFile(file, matter.stringify(parsed.content, yamlSafe(data)));
|
|
2738
|
+
});
|
|
2739
|
+
const settingKeys = Object.keys(patch).filter((k) => k !== "orchestratorSessionId");
|
|
2740
|
+
if (settingKeys.length) await commitBoardMd(project2, settingKeys.join(", "));
|
|
2741
|
+
}
|
|
2742
|
+
function laneDefsFor(mode) {
|
|
2743
|
+
if (mode === "remote") return LANES;
|
|
2744
|
+
const localTitles = {
|
|
2745
|
+
"pr-review": { title: "Review", blurb: "Independent local reviews; fix findings. No PR." },
|
|
2746
|
+
ci: { title: "Merge", blurb: "Merge into local trunk and delete the branch." },
|
|
2747
|
+
"prod-testing": { title: "Verify", blurb: "Verify the merged trunk locally." }
|
|
2748
|
+
};
|
|
2749
|
+
return LANES.map((l) => localTitles[l.id] ? { ...l, ...localTitles[l.id] } : l);
|
|
2750
|
+
}
|
|
2751
|
+
function frontmatterToTask(fm, lane, fallbackId) {
|
|
2752
|
+
return {
|
|
2753
|
+
id: fm.id ?? fallbackId,
|
|
2754
|
+
title: fm.title || "",
|
|
2755
|
+
shortDescription: fm.shortDescription ?? "",
|
|
2756
|
+
status: lane,
|
|
2757
|
+
order: fm.order ?? 0,
|
|
2758
|
+
hasDoc: true,
|
|
2759
|
+
sessionId: fm.sessionId,
|
|
2760
|
+
worktree: fm.worktree,
|
|
2761
|
+
branch: fm.branch,
|
|
2762
|
+
model: fm.model,
|
|
2763
|
+
effort: fm.effort,
|
|
2764
|
+
size: fm.size,
|
|
2765
|
+
permissionMode: fm.permissionMode,
|
|
2766
|
+
autonomous: fm.autonomous,
|
|
2767
|
+
forks: fm.forks,
|
|
2768
|
+
prUrl: fm.prUrl,
|
|
2769
|
+
prNumber: fm.prNumber,
|
|
2770
|
+
createdAt: fm.createdAt ?? (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
2771
|
+
doneAt: fm.doneAt,
|
|
2772
|
+
workflowEnteredAt: fm.workflowEnteredAt,
|
|
2773
|
+
pendingKickoff: fm.pendingKickoff,
|
|
2774
|
+
kickedLane: fm.kickedLane,
|
|
2775
|
+
proposedLane: fm.proposedLane,
|
|
2776
|
+
grooming: fm.grooming
|
|
2777
|
+
};
|
|
2778
|
+
}
|
|
2779
|
+
async function readTaskDoc(project2, id) {
|
|
2780
|
+
const found = await findTaskFile(project2, id);
|
|
2781
|
+
if (!found) return null;
|
|
2782
|
+
const parsed = matter(await fs4.readFile(found.file, "utf8"));
|
|
2783
|
+
return { data: parseFrontmatter(parsed.data), body: parsed.content, file: found.file };
|
|
2784
|
+
}
|
|
2785
|
+
async function patchTaskFrontmatter(project2, id, patch) {
|
|
2786
|
+
const { written, file } = await mutateTaskDoc(project2, id, ({ data, body }) => ({
|
|
2787
|
+
data: { ...data, ...patch },
|
|
2788
|
+
body
|
|
2789
|
+
}));
|
|
2790
|
+
if (!written || !file) return false;
|
|
2791
|
+
const keys = Object.keys(patch);
|
|
2792
|
+
if (keys.some((k) => !EPHEMERAL_FRONTMATTER.has(k))) {
|
|
2793
|
+
await commitBoard(project2, [file], `update ${id} (${keys.join(", ")})`);
|
|
2794
|
+
}
|
|
2795
|
+
return true;
|
|
2796
|
+
}
|
|
2797
|
+
async function patchFork(project2, taskId, forkKey, patch) {
|
|
2798
|
+
await mutateTaskDoc(project2, taskId, ({ data, body }) => ({
|
|
2799
|
+
data: { ...data, forks: (data.forks ?? []).map((f) => f.key === forkKey ? { ...f, ...patch } : f) },
|
|
2800
|
+
body
|
|
2801
|
+
}));
|
|
2802
|
+
}
|
|
2803
|
+
async function patchForkSession(project2, taskId, forkKey, sessionId2) {
|
|
2804
|
+
await patchFork(project2, taskId, forkKey, { sessionId: sessionId2 });
|
|
2805
|
+
}
|
|
2806
|
+
async function patchForkLabel(project2, taskId, forkKey, label) {
|
|
2807
|
+
await patchFork(project2, taskId, forkKey, { label });
|
|
2808
|
+
}
|
|
2809
|
+
async function mutateOrchestratorForks(project2, mutate) {
|
|
2810
|
+
const file = boardMdFile(project2);
|
|
2811
|
+
await withFileLock(file, async () => {
|
|
2812
|
+
let parsed;
|
|
2813
|
+
try {
|
|
2814
|
+
parsed = matter(await fs4.readFile(file, "utf8"));
|
|
2815
|
+
} catch {
|
|
2816
|
+
await ensureSkeleton(project2);
|
|
2817
|
+
parsed = matter(await fs4.readFile(file, "utf8"));
|
|
2818
|
+
}
|
|
2819
|
+
const current = BoardFrontmatterSchema.parse(parsed.data).orchestratorForks ?? [];
|
|
2820
|
+
const next = mutate(current);
|
|
2821
|
+
const data = { ...parsed.data };
|
|
2822
|
+
if (next.length) data.orchestratorForks = next;
|
|
2823
|
+
else delete data.orchestratorForks;
|
|
2824
|
+
await atomicWriteFile(file, matter.stringify(parsed.content, yamlSafe(data)));
|
|
2825
|
+
});
|
|
2826
|
+
}
|
|
2827
|
+
async function patchOrchestratorFork(project2, forkKey, patch) {
|
|
2828
|
+
await mutateOrchestratorForks(
|
|
2829
|
+
project2,
|
|
2830
|
+
(forks) => forks.map((f) => f.key === forkKey ? { ...f, ...patch } : f)
|
|
2831
|
+
);
|
|
2832
|
+
}
|
|
2833
|
+
async function patchOrchestratorForkSession(project2, forkKey, sessionId2) {
|
|
2834
|
+
await patchOrchestratorFork(project2, forkKey, { sessionId: sessionId2 });
|
|
2835
|
+
}
|
|
2836
|
+
async function patchOrchestratorForkLabel(project2, forkKey, label) {
|
|
2837
|
+
await patchOrchestratorFork(project2, forkKey, { label });
|
|
2838
|
+
}
|
|
2839
|
+
async function readFreshDoc(file) {
|
|
2840
|
+
try {
|
|
2841
|
+
const parsed = matter(await fs4.readFile(file, "utf8"));
|
|
2842
|
+
return { data: parseFrontmatter(parsed.data), body: parsed.content };
|
|
2843
|
+
} catch {
|
|
2844
|
+
return null;
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
async function mutateTaskDoc(project2, id, mutate) {
|
|
2848
|
+
return withTaskMutex(project2, id, () => mutateTaskDocSerialized(project2, id, mutate));
|
|
2849
|
+
}
|
|
2850
|
+
async function mutateTaskDocSerialized(project2, id, mutate) {
|
|
2851
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
2852
|
+
const found = await findTaskFile(project2, id);
|
|
2853
|
+
if (!found) return { written: false, file: null };
|
|
2854
|
+
const file = found.file;
|
|
2855
|
+
let outcome = "moved";
|
|
2856
|
+
await withFileLock(file, async () => {
|
|
2857
|
+
const fresh = await readFreshDoc(file);
|
|
2858
|
+
if (!fresh) {
|
|
2859
|
+
outcome = "moved";
|
|
2860
|
+
return;
|
|
2861
|
+
}
|
|
2862
|
+
const next = mutate(fresh);
|
|
2863
|
+
if (!next) {
|
|
2864
|
+
outcome = "skip";
|
|
2865
|
+
return;
|
|
2866
|
+
}
|
|
2867
|
+
await atomicWriteFile(file, matter.stringify(next.body, yamlSafe(next.data)));
|
|
2868
|
+
outcome = "written";
|
|
2869
|
+
});
|
|
2870
|
+
if (outcome === "moved") continue;
|
|
2871
|
+
return { written: outcome === "written", file };
|
|
2872
|
+
}
|
|
2873
|
+
return { written: false, file: null };
|
|
2874
|
+
}
|
|
2875
|
+
async function updateTask(project2, id, patch) {
|
|
2876
|
+
const { written, file } = await mutateTaskDoc(project2, id, ({ data: fresh, body: freshBody }) => {
|
|
2877
|
+
const data = { ...fresh };
|
|
2878
|
+
if (patch.title !== void 0) data.title = patch.title;
|
|
2879
|
+
if (patch.shortDescription !== void 0) data.shortDescription = patch.shortDescription;
|
|
2880
|
+
if (patch.effort !== void 0) data.effort = patch.effort;
|
|
2881
|
+
if (patch.size !== void 0) data.size = patch.size;
|
|
2882
|
+
if (patch.grooming !== void 0) {
|
|
2883
|
+
if (patch.grooming) data.grooming = true;
|
|
2884
|
+
else delete data.grooming;
|
|
2885
|
+
}
|
|
2886
|
+
data.lastGroomedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2887
|
+
const body = patch.fields ? patchTaskBody(freshBody, patch.fields, patch.appendFields) : freshBody;
|
|
2888
|
+
return { data, body };
|
|
2889
|
+
});
|
|
2890
|
+
if (!written || !file) return false;
|
|
2891
|
+
await commitBoard(project2, [file], `update task ${id}`);
|
|
2892
|
+
return true;
|
|
2893
|
+
}
|
|
2894
|
+
async function appendTaskField(project2, id, markdown, field = "notes", opts = {}) {
|
|
2895
|
+
const { written, file } = await mutateTaskDoc(project2, id, ({ data, body }) => ({
|
|
2896
|
+
data: { ...data, lastDocUpdatedAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
2897
|
+
body: patchTaskBody(body, { [field]: markdown.trim() }, [field])
|
|
2898
|
+
}));
|
|
2899
|
+
if (written && file && opts.commit !== false) await commitBoard(project2, [file], `append to task ${id}`);
|
|
2900
|
+
return written;
|
|
2901
|
+
}
|
|
2902
|
+
async function patchTaskFields(project2, id, fields, opts = {}) {
|
|
2903
|
+
const { written, file } = await mutateTaskDoc(project2, id, ({ data, body }) => ({
|
|
2904
|
+
data: { ...data, lastDocUpdatedAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
2905
|
+
body: patchTaskBody(body, fields)
|
|
2906
|
+
}));
|
|
2907
|
+
if (written && file && opts.commit !== false) await commitBoard(project2, [file], `update test brief ${id}`);
|
|
2908
|
+
return written;
|
|
2909
|
+
}
|
|
2910
|
+
async function readBoard(project2) {
|
|
2911
|
+
const config2 = await readBoardConfig(project2);
|
|
2912
|
+
const projectWithConfig = applyBoardConfig(project2, config2);
|
|
2913
|
+
const byLane = /* @__PURE__ */ new Map();
|
|
2914
|
+
for (const bucket of BUCKETS) {
|
|
2915
|
+
for (const file of await listBucketFiles(project2, bucket)) {
|
|
2916
|
+
let fm;
|
|
2917
|
+
try {
|
|
2918
|
+
fm = parseFrontmatter(matter(await fs4.readFile(file, "utf8")).data);
|
|
2919
|
+
} catch {
|
|
2920
|
+
continue;
|
|
2921
|
+
}
|
|
2922
|
+
const lane = laneForBucketFile(bucket, fm.status);
|
|
2923
|
+
let arr = byLane.get(lane);
|
|
2924
|
+
if (!arr) byLane.set(lane, arr = []);
|
|
2925
|
+
arr.push(frontmatterToTask(fm, lane, idFromFile(file)));
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
const byId = /* @__PURE__ */ new Map();
|
|
2929
|
+
for (const lane of LANE_IDS) {
|
|
2930
|
+
const laneTasks = byLane.get(lane);
|
|
2931
|
+
if (!laneTasks) continue;
|
|
2932
|
+
laneTasks.sort((a, b) => compareTasksInLane(lane, a, b));
|
|
2933
|
+
for (const t of laneTasks) byId.set(t.id, t);
|
|
2934
|
+
}
|
|
2935
|
+
const tasks = [];
|
|
2936
|
+
for (const lane of LANE_IDS) {
|
|
2937
|
+
for (const t of byId.values()) if (t.status === lane) tasks.push(t);
|
|
2938
|
+
}
|
|
2939
|
+
return { project: projectWithConfig, lanes: laneDefsFor(config2.reviewMode), tasks };
|
|
2940
|
+
}
|
|
2941
|
+
async function laneEntries(project2, lane, excludeId) {
|
|
2942
|
+
const entries = [];
|
|
2943
|
+
for (const file of await listLaneFiles(project2, lane)) {
|
|
2944
|
+
if (excludeId && matchesTaskId(basename2(file), excludeId)) continue;
|
|
2945
|
+
try {
|
|
2946
|
+
const fm = parseFrontmatter(matter(await fs4.readFile(file, "utf8")).data);
|
|
2947
|
+
entries.push({ file, order: fm.order ?? 0 });
|
|
2948
|
+
} catch {
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
return entries.sort((a, b) => a.order - b.order);
|
|
2952
|
+
}
|
|
2953
|
+
async function laneOrders(project2, lane, excludeId) {
|
|
2954
|
+
return (await laneEntries(project2, lane, excludeId)).map((e) => e.order);
|
|
2955
|
+
}
|
|
2956
|
+
async function renormalizeLane(project2, lane) {
|
|
2957
|
+
const entries = await laneEntries(project2, lane);
|
|
2958
|
+
const bucket = bucketForLane(lane);
|
|
2959
|
+
await Promise.all(
|
|
2960
|
+
entries.map(async ({ file }, i) => {
|
|
2961
|
+
await withFileLock(file, async () => {
|
|
2962
|
+
const fresh = await readFreshDoc(file);
|
|
2963
|
+
if (!fresh) return;
|
|
2964
|
+
if (laneForBucketFile(bucket, fresh.data.status) !== lane) return;
|
|
2965
|
+
if (fresh.data.order === i) return;
|
|
2966
|
+
await atomicWriteFile(
|
|
2967
|
+
file,
|
|
2968
|
+
matter.stringify(fresh.body, yamlSafe({ ...fresh.data, order: i }))
|
|
2969
|
+
);
|
|
2970
|
+
});
|
|
2971
|
+
})
|
|
2972
|
+
);
|
|
2973
|
+
}
|
|
2974
|
+
async function createTask(project2, input, lane = "backlog") {
|
|
2975
|
+
const id = newTaskId();
|
|
2976
|
+
const title = input.title.trim();
|
|
2977
|
+
const desc = input.shortDescription.trim();
|
|
2978
|
+
const orders = await laneOrders(project2, lane);
|
|
2979
|
+
const order = orders.length ? orders[orders.length - 1] + 1 : 0;
|
|
2980
|
+
const data = {
|
|
2981
|
+
id,
|
|
2982
|
+
title,
|
|
2983
|
+
shortDescription: desc,
|
|
2984
|
+
status: lane,
|
|
2985
|
+
order,
|
|
2986
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2987
|
+
};
|
|
2988
|
+
const body = input.fields ? serializeTaskBody(input.fields) : input.body ? serializeTaskBody(parseTaskBody(input.body)) : "";
|
|
2989
|
+
await fs4.mkdir(laneDir(project2, lane), { recursive: true });
|
|
2990
|
+
const file = join5(laneDir(project2, lane), taskFileName(id, title || desc));
|
|
2991
|
+
await atomicWriteFile(file, matter.stringify(body, yamlSafe(data)));
|
|
2992
|
+
const laneNow = await laneOrders(project2, lane);
|
|
2993
|
+
if (new Set(laneNow).size !== laneNow.length) {
|
|
2994
|
+
await renormalizeLane(project2, lane);
|
|
2995
|
+
const fresh = await readFreshDoc(file);
|
|
2996
|
+
if (fresh?.data.order !== void 0) data.order = fresh.data.order;
|
|
2997
|
+
}
|
|
2998
|
+
await commitBoard(project2, [file], `add task ${id} \u2014 ${title} [${lane}]`);
|
|
2999
|
+
return frontmatterToTask(data, lane, id);
|
|
3000
|
+
}
|
|
3001
|
+
async function exists(p) {
|
|
3002
|
+
try {
|
|
3003
|
+
await fs4.access(p);
|
|
3004
|
+
return true;
|
|
3005
|
+
} catch {
|
|
3006
|
+
return false;
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
// apps/server/src/runner/protocol.ts
|
|
3011
|
+
import { join as join6 } from "node:path";
|
|
3012
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
3013
|
+
import { tmpdir } from "node:os";
|
|
3014
|
+
var PROTOCOL_VERSION = 2;
|
|
3015
|
+
function writeMsg(socket, msg) {
|
|
3016
|
+
if (socket.writable) socket.write(JSON.stringify(msg) + "\n");
|
|
3017
|
+
}
|
|
3018
|
+
function attachLineReader(socket, onMessage) {
|
|
3019
|
+
let buf = "";
|
|
3020
|
+
socket.on("data", (d) => {
|
|
3021
|
+
buf += d.toString();
|
|
3022
|
+
let nl;
|
|
3023
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
3024
|
+
const line = buf.slice(0, nl);
|
|
3025
|
+
buf = buf.slice(nl + 1);
|
|
3026
|
+
if (line.trim()) onMessage(line);
|
|
3027
|
+
}
|
|
3028
|
+
});
|
|
3029
|
+
}
|
|
3030
|
+
function runnersDir(projectPath) {
|
|
3031
|
+
return join6(operatorDir(projectPath), ".runners");
|
|
3032
|
+
}
|
|
3033
|
+
function sockPath(projectPath, key) {
|
|
3034
|
+
const full = join6(runnersDir(projectPath), `${sanitizeKey(key)}.sock`);
|
|
3035
|
+
if (Buffer.byteLength(full) <= 100) return full;
|
|
3036
|
+
const digest = createHash3("sha1").update(key).digest("hex").slice(0, 16);
|
|
3037
|
+
const inDir = join6(runnersDir(projectPath), `s-${digest}.sock`);
|
|
3038
|
+
if (Buffer.byteLength(inDir) <= 100) return inDir;
|
|
3039
|
+
const scoped = createHash3("sha1").update(`${projectPath}\0${key}`).digest("hex").slice(0, 16);
|
|
3040
|
+
return join6(tmpdir(), `operator-${scoped}.sock`);
|
|
3041
|
+
}
|
|
3042
|
+
function metaPath(projectPath, key) {
|
|
3043
|
+
return join6(runnersDir(projectPath), `${sanitizeKey(key)}.json`);
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
// apps/server/src/runner/outbox.ts
|
|
3047
|
+
var Outbox = class {
|
|
3048
|
+
constructor(write, genId) {
|
|
3049
|
+
this.write = write;
|
|
3050
|
+
this.genId = genId;
|
|
3051
|
+
}
|
|
3052
|
+
items = /* @__PURE__ */ new Map();
|
|
3053
|
+
/** Assign a delivery id, remember the request, and send it now (best-effort). */
|
|
3054
|
+
enqueue(msg) {
|
|
3055
|
+
const id = msg.id ?? this.genId();
|
|
3056
|
+
const withId = { ...msg, id };
|
|
3057
|
+
this.items.set(id, withId);
|
|
3058
|
+
this.write(withId);
|
|
3059
|
+
return id;
|
|
3060
|
+
}
|
|
3061
|
+
/** Drop an acknowledged request so it isn't replayed. */
|
|
3062
|
+
ack(id) {
|
|
3063
|
+
this.items.delete(id);
|
|
3064
|
+
}
|
|
3065
|
+
/** Re-send every still-unacked request — call on each (re)connection. */
|
|
3066
|
+
replay(write) {
|
|
3067
|
+
for (const m of this.items.values()) write(m);
|
|
3068
|
+
}
|
|
3069
|
+
/** Count of unacked requests (for tests / introspection). */
|
|
3070
|
+
get size() {
|
|
3071
|
+
return this.items.size;
|
|
3072
|
+
}
|
|
3073
|
+
};
|
|
3074
|
+
|
|
3075
|
+
// apps/server/src/runner/fakeAgentSession.ts
|
|
3076
|
+
import { promises as fs5 } from "node:fs";
|
|
3077
|
+
import { execFile } from "node:child_process";
|
|
3078
|
+
import { promisify } from "node:util";
|
|
3079
|
+
import { join as join7 } from "node:path";
|
|
3080
|
+
var execFileP = promisify(execFile);
|
|
3081
|
+
async function execRetry(cmd, args, opts) {
|
|
3082
|
+
try {
|
|
3083
|
+
return await execFileP(cmd, args, opts);
|
|
3084
|
+
} catch (e) {
|
|
3085
|
+
if (!/spawn\s+\S+\s+(ENOENT|EAGAIN)/i.test(String(e))) throw e;
|
|
3086
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
3087
|
+
return execFileP(cmd, args, opts);
|
|
3088
|
+
}
|
|
3089
|
+
}
|
|
3090
|
+
function fakeAgentScriptPath(dir, key) {
|
|
3091
|
+
return join7(dir, `${key.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`);
|
|
3092
|
+
}
|
|
3093
|
+
var FakeAgentSession = class {
|
|
3094
|
+
constructor(hooks, scriptFile) {
|
|
3095
|
+
this.hooks = hooks;
|
|
3096
|
+
this.scriptFile = scriptFile;
|
|
3097
|
+
}
|
|
3098
|
+
turnsUsed = /* @__PURE__ */ new Set();
|
|
3099
|
+
/** Bumped by interrupt() AND by each new send(): a step loop only continues
|
|
3100
|
+
* while it still owns the current generation, so an interrupted (or
|
|
3101
|
+
* superseded) turn stops at its next step instead of running concurrently
|
|
3102
|
+
* with the rerun. */
|
|
3103
|
+
gen = 0;
|
|
3104
|
+
async script() {
|
|
3105
|
+
try {
|
|
3106
|
+
return JSON.parse(await fs5.readFile(this.scriptFile, "utf8"));
|
|
3107
|
+
} catch {
|
|
3108
|
+
return { turns: [] };
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3111
|
+
async send(text) {
|
|
3112
|
+
const my = ++this.gen;
|
|
3113
|
+
const { onEvent } = this.hooks;
|
|
3114
|
+
await Promise.resolve(this.hooks.onSessionId(`fake-${Date.now().toString(36)}`));
|
|
3115
|
+
onEvent({ type: "status", state: "running" });
|
|
3116
|
+
const script = await this.script();
|
|
3117
|
+
const idx = script.turns.findIndex(
|
|
3118
|
+
(t, i) => !this.turnsUsed.has(i) && (!t.match || new RegExp(t.match, "i").test(text))
|
|
3119
|
+
);
|
|
3120
|
+
const turn = idx >= 0 ? script.turns[idx] : { steps: [{ text: "(fake agent: no scripted turn \u2014 idling)" }] };
|
|
3121
|
+
if (idx >= 0) this.turnsUsed.add(idx);
|
|
3122
|
+
const boom = turn.steps.find((st) => st.throwError);
|
|
3123
|
+
if (boom) throw new Error(boom.throwError);
|
|
3124
|
+
try {
|
|
3125
|
+
for (const step of turn.steps) {
|
|
3126
|
+
if (this.gen !== my) return;
|
|
3127
|
+
if (step.sleepMs) {
|
|
3128
|
+
for (let left = step.sleepMs; left > 0 && this.gen === my; left -= 100) {
|
|
3129
|
+
await new Promise((r) => setTimeout(r, Math.min(100, left)));
|
|
3130
|
+
}
|
|
3131
|
+
if (this.gen !== my) return;
|
|
3132
|
+
}
|
|
3133
|
+
if (step.text) onEvent({ type: "assistant_text", text: step.text });
|
|
3134
|
+
if (step.writeFile) {
|
|
3135
|
+
await fs5.writeFile(join7(this.hooks.cwd, step.writeFile.path), step.writeFile.content);
|
|
3136
|
+
onEvent({ type: "tool_use", id: `fk-${Date.now().toString(36)}`, name: "Write", input: { file_path: step.writeFile.path } });
|
|
3137
|
+
}
|
|
3138
|
+
if (step.commit) {
|
|
3139
|
+
await execRetry("git", ["add", "-A"], { cwd: this.hooks.cwd });
|
|
3140
|
+
await execRetry("git", ["commit", "-m", step.commit, "--no-verify"], { cwd: this.hooks.cwd });
|
|
3141
|
+
}
|
|
3142
|
+
if (step.run) {
|
|
3143
|
+
try {
|
|
3144
|
+
await execRetry("sh", ["-c", step.run], { cwd: this.hooks.cwd });
|
|
3145
|
+
} catch (err) {
|
|
3146
|
+
onEvent({ type: "error", message: `fake agent run failed: ${String(err).slice(0, 300)}` });
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
if (step.throwError) throw new Error(step.throwError);
|
|
3150
|
+
if (step.runTests && this.hooks.runTests) {
|
|
3151
|
+
const id = this.hooks.runTests(step.runTests.name, step.runTests.command);
|
|
3152
|
+
onEvent({ type: "assistant_text", text: `(fake agent) run_tests queued: ${step.runTests.name} (${id})` });
|
|
3153
|
+
}
|
|
3154
|
+
if (step.doc) await this.hooks.appendDoc(step.doc);
|
|
3155
|
+
if (step.permission) {
|
|
3156
|
+
const r = await this.hooks.requestPermission(step.permission.toolName, step.permission.input ?? {});
|
|
3157
|
+
onEvent({
|
|
3158
|
+
type: "assistant_text",
|
|
3159
|
+
text: r.allow ? `(fake agent) permission allowed: ${step.permission.toolName}` : `(fake agent) permission denied: ${r.message ?? "(no message)"}`
|
|
3160
|
+
});
|
|
3161
|
+
}
|
|
3162
|
+
if (step.orchestrate) {
|
|
3163
|
+
const o = step.orchestrate;
|
|
3164
|
+
if (o.createTask) {
|
|
3165
|
+
const id = this.hooks.orchestrateCreateTask ? await this.hooks.orchestrateCreateTask(o.createTask.title, o.createTask.summary ?? "", o.createTask.fields) : null;
|
|
3166
|
+
onEvent({ type: "assistant_text", text: `(fake agent) create_task: ${id ?? "FAILED"}` });
|
|
3167
|
+
}
|
|
3168
|
+
if (o.updateTask) {
|
|
3169
|
+
const { id, ...patch } = o.updateTask;
|
|
3170
|
+
const ok3 = this.hooks.orchestrateUpdateTask ? await this.hooks.orchestrateUpdateTask(id, patch) : false;
|
|
3171
|
+
onEvent({ type: "assistant_text", text: `(fake agent) update_task ${id}: ${ok3 ? "updated" : "FAILED"}` });
|
|
3172
|
+
}
|
|
3173
|
+
if (o.moveTask) {
|
|
3174
|
+
const ok3 = this.hooks.orchestrateMoveTask ? await this.hooks.orchestrateMoveTask(o.moveTask.id, o.moveTask.toLane) : false;
|
|
3175
|
+
onEvent({ type: "assistant_text", text: `(fake agent) move_task ${o.moveTask.id} -> ${o.moveTask.toLane}: ${ok3 ? "requested" : "FAILED"}` });
|
|
3176
|
+
}
|
|
3177
|
+
if (o.listTasks) {
|
|
3178
|
+
const tasks = this.hooks.orchestrateListTasks ? await this.hooks.orchestrateListTasks() : null;
|
|
3179
|
+
onEvent({ type: "assistant_text", text: `(fake agent) list_tasks: ${tasks ? JSON.stringify(tasks) : "FAILED"}` });
|
|
3180
|
+
}
|
|
3181
|
+
if (o.getTask) {
|
|
3182
|
+
const t = this.hooks.orchestrateGetTask ? await this.hooks.orchestrateGetTask(o.getTask.id) : null;
|
|
3183
|
+
onEvent({ type: "assistant_text", text: `(fake agent) get_task ${o.getTask.id}: ${t ? JSON.stringify(t) : "null"}` });
|
|
3184
|
+
}
|
|
3185
|
+
if (o.foldTask) {
|
|
3186
|
+
const ok3 = this.hooks.orchestrateFoldTask ? await this.hooks.orchestrateFoldTask(o.foldTask.placeholderId, o.foldTask.intoIds) : false;
|
|
3187
|
+
onEvent({ type: "assistant_text", text: `(fake agent) fold_task ${o.foldTask.placeholderId} -> ${o.foldTask.intoIds.join(",")}: ${ok3 ? "requested" : "FAILED"}` });
|
|
3188
|
+
}
|
|
3189
|
+
if (o.deleteTask) {
|
|
3190
|
+
const ok3 = this.hooks.orchestrateDeleteTask ? await this.hooks.orchestrateDeleteTask(o.deleteTask.id) : false;
|
|
3191
|
+
onEvent({ type: "assistant_text", text: `(fake agent) delete_task ${o.deleteTask.id}: ${ok3 ? "requested" : "FAILED"}` });
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
if (step.clarify) {
|
|
3195
|
+
const answer = await this.hooks.requestClarification(step.clarify.question, step.clarify.options);
|
|
3196
|
+
onEvent({ type: "assistant_text", text: `(fake agent) clarification answered: ${answer}` });
|
|
3197
|
+
}
|
|
3198
|
+
if (step.propose) {
|
|
3199
|
+
const r = await this.hooks.proposeAdvance(
|
|
3200
|
+
step.propose.toLane,
|
|
3201
|
+
step.propose.rationale,
|
|
3202
|
+
step.propose.confident ?? true
|
|
3203
|
+
);
|
|
3204
|
+
if (typeof r === "string") onEvent({ type: "assistant_text", text: `(fake agent) proposal feedback received: ${r.slice(0, 200)}` });
|
|
3205
|
+
else onEvent({ type: "assistant_text", text: `(fake agent) proposal resolved: ${r}` });
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
if (this.gen !== my) return;
|
|
3209
|
+
onEvent({ type: "result", subtype: "success", isError: false });
|
|
3210
|
+
} catch (err) {
|
|
3211
|
+
onEvent({ type: "error", message: `fake agent step failed: ${String(err)}` });
|
|
3212
|
+
} finally {
|
|
3213
|
+
if (this.gen === my) onEvent({ type: "status", state: "idle" });
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
async interrupt() {
|
|
3217
|
+
this.gen++;
|
|
3218
|
+
this.hooks.onEvent({ type: "status", state: "idle" });
|
|
3219
|
+
}
|
|
3220
|
+
// Surface parity with AgentSession (the runner calls these unconditionally).
|
|
3221
|
+
async stop() {
|
|
3222
|
+
}
|
|
3223
|
+
async retry() {
|
|
3224
|
+
}
|
|
3225
|
+
async rewind(_toUuid, _preview) {
|
|
3226
|
+
}
|
|
3227
|
+
async setModel(_m) {
|
|
3228
|
+
}
|
|
3229
|
+
setEffort(_e) {
|
|
3230
|
+
}
|
|
3231
|
+
async setPermissionMode(_m) {
|
|
3232
|
+
}
|
|
3233
|
+
setAutonomous(_v) {
|
|
3234
|
+
}
|
|
3235
|
+
setAuthToken(_t) {
|
|
3236
|
+
}
|
|
3237
|
+
};
|
|
3238
|
+
|
|
3239
|
+
// apps/server/src/runner/main.ts
|
|
3240
|
+
import { join as join8 } from "node:path";
|
|
3241
|
+
var configPath = process.argv[2];
|
|
3242
|
+
var config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
3243
|
+
var project = {
|
|
3244
|
+
id: config.projectId,
|
|
3245
|
+
name: config.projectName,
|
|
3246
|
+
path: config.projectPath,
|
|
3247
|
+
trunk: config.trunk,
|
|
3248
|
+
reviewMode: "local",
|
|
3249
|
+
autoExpand: true,
|
|
3250
|
+
// Auto-advance policy (dev-speed + step gates) lives on the server; the runner
|
|
3251
|
+
// only needs placeholders to satisfy the Project shape.
|
|
3252
|
+
devSpeed: "enterprise",
|
|
3253
|
+
stepGates: {},
|
|
3254
|
+
phaseHooks: {},
|
|
3255
|
+
iframeCaching: "eager"
|
|
3256
|
+
};
|
|
3257
|
+
var conns = /* @__PURE__ */ new Set();
|
|
3258
|
+
var pending = /* @__PURE__ */ new Map();
|
|
3259
|
+
var sessionId = config.resumeSessionId;
|
|
3260
|
+
var sessionState = "idle";
|
|
3261
|
+
var accountId = config.accountId;
|
|
3262
|
+
var authToken = config.env?.CLAUDE_CODE_OAUTH_TOKEN;
|
|
3263
|
+
var limitStalled = false;
|
|
3264
|
+
var throttle = null;
|
|
3265
|
+
var slashCommands = [];
|
|
3266
|
+
var IDLE_MS = Number(process.env.OPERATOR_RUNNER_IDLE_MS ?? 5 * 60 * 1e3);
|
|
3267
|
+
var SESSION_IDLE_MS = Number(process.env.OPERATOR_RUNNER_SESSION_IDLE_MS ?? 15 * 60 * 1e3);
|
|
3268
|
+
var disconnectedAt = Date.now();
|
|
3269
|
+
var lastActiveAt = Date.now();
|
|
3270
|
+
function onConnected() {
|
|
3271
|
+
disconnectedAt = null;
|
|
3272
|
+
lastActiveAt = Date.now();
|
|
3273
|
+
}
|
|
3274
|
+
function onDisconnected() {
|
|
3275
|
+
if (conns.size === 0) disconnectedAt = Date.now();
|
|
3276
|
+
}
|
|
3277
|
+
function toServer(msg) {
|
|
3278
|
+
for (const c of conns) writeMsg(c, msg);
|
|
3279
|
+
}
|
|
3280
|
+
var outbox = new Outbox(toServer, () => nanoid2(10));
|
|
3281
|
+
var emitChain = Promise.resolve();
|
|
3282
|
+
function actionForEvent(event) {
|
|
3283
|
+
switch (event.type) {
|
|
3284
|
+
case "tool_use":
|
|
3285
|
+
return { actor: "agent", action: "tool_use", detail: { name: event.name } };
|
|
3286
|
+
case "tool_result":
|
|
3287
|
+
return { actor: "agent", action: "tool_result", detail: { isError: event.isError ?? false } };
|
|
3288
|
+
case "permission_request":
|
|
3289
|
+
return { actor: "agent", action: "permission_request", detail: { toolName: event.toolName } };
|
|
3290
|
+
case "clarification":
|
|
3291
|
+
return { actor: "agent", action: "clarification" };
|
|
3292
|
+
case "ask_user_question":
|
|
3293
|
+
return { actor: "agent", action: "ask_user_question", detail: { count: event.questions.length } };
|
|
3294
|
+
case "advance_proposal":
|
|
3295
|
+
return { actor: "agent", action: "advance_proposal", detail: { toLane: event.toLane, confident: event.confident } };
|
|
3296
|
+
case "user_message":
|
|
3297
|
+
return { actor: "user", action: "message" };
|
|
3298
|
+
default:
|
|
3299
|
+
return null;
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
function emit(event) {
|
|
3303
|
+
lastActiveAt = Date.now();
|
|
3304
|
+
const action = actionForEvent(event);
|
|
3305
|
+
if (action) void logAction(project, { key: config.key, ...action });
|
|
3306
|
+
if (event.type === "slash_commands") slashCommands = event.commands;
|
|
3307
|
+
if (event.type === "session_init") sessionId = event.sessionId;
|
|
3308
|
+
if (event.type === "status") {
|
|
3309
|
+
sessionState = event.state;
|
|
3310
|
+
if (event.state === "running") limitStalled = false;
|
|
3311
|
+
if (event.state !== "throttled") throttle = null;
|
|
3312
|
+
}
|
|
3313
|
+
if (event.type === "throttled") {
|
|
3314
|
+
throttle = { resetsAt: event.resetsAt, limitType: event.limitType };
|
|
3315
|
+
if (event.resetsAt) limitStalled = true;
|
|
3316
|
+
}
|
|
3317
|
+
const persisted = event.type !== "rate_limit" && event.type !== "slash_commands";
|
|
3318
|
+
emitChain = emitChain.then(async () => {
|
|
3319
|
+
const seq = persisted ? await appendEvent(project, config.key, event) : null;
|
|
3320
|
+
toServer({ t: "event", event, ...seq !== null ? { seq } : {} });
|
|
3321
|
+
});
|
|
3322
|
+
if (event.type === "error" && !cwdGone) void abortIfCwdGone();
|
|
3323
|
+
}
|
|
3324
|
+
var cwdGone = false;
|
|
3325
|
+
async function cwdExists() {
|
|
3326
|
+
try {
|
|
3327
|
+
await fs6.access(config.cwd);
|
|
3328
|
+
return true;
|
|
3329
|
+
} catch {
|
|
3330
|
+
return false;
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
async function abortIfCwdGone() {
|
|
3334
|
+
if (cwdGone) return true;
|
|
3335
|
+
if (await cwdExists()) return false;
|
|
3336
|
+
cwdGone = true;
|
|
3337
|
+
emit({
|
|
3338
|
+
type: "error",
|
|
3339
|
+
message: `Working directory no longer exists: ${config.cwd}. The task's git worktree was removed (typically after its branch merged), so this agent can't continue here. Stopping it \u2014 move the card or restart the task to run in a fresh worktree.`
|
|
3340
|
+
});
|
|
3341
|
+
emit({ type: "status", state: "error" });
|
|
3342
|
+
void shutdown("worktree (cwd) removed");
|
|
3343
|
+
return true;
|
|
3344
|
+
}
|
|
3345
|
+
function awaitResolution(kind, register, opts) {
|
|
3346
|
+
return new Promise((resolve) => {
|
|
3347
|
+
const id = nanoid2(10);
|
|
3348
|
+
pending.set(id, { kind, resolve });
|
|
3349
|
+
register(id);
|
|
3350
|
+
if (opts) {
|
|
3351
|
+
const timer = setTimeout(() => {
|
|
3352
|
+
if (!pending.has(id)) return;
|
|
3353
|
+
pending.delete(id);
|
|
3354
|
+
resolve(opts.onTimeout());
|
|
3355
|
+
}, opts.timeoutMs);
|
|
3356
|
+
timer.unref();
|
|
3357
|
+
}
|
|
3358
|
+
});
|
|
3359
|
+
}
|
|
3360
|
+
function taskMcpContext(taskId) {
|
|
3361
|
+
const docTaskId = parentTaskId(taskId);
|
|
3362
|
+
return {
|
|
3363
|
+
taskId,
|
|
3364
|
+
project,
|
|
3365
|
+
requestClarification: (question, options) => awaitResolution(
|
|
3366
|
+
"clarification",
|
|
3367
|
+
(id) => emit({ type: "clarification", id, question, options })
|
|
3368
|
+
),
|
|
3369
|
+
proposeAdvance: (toLane, rationale, confident) => awaitResolution(
|
|
3370
|
+
"advance",
|
|
3371
|
+
(id) => emit({ type: "advance_proposal", id, toLane, rationale: rationale ?? "", confident })
|
|
3372
|
+
),
|
|
3373
|
+
// Throws on a miss so the MCP tool returns an error (the SDK turns a handler
|
|
3374
|
+
// throw into an isError tool result) instead of a phantom "updated". Fork
|
|
3375
|
+
// sessions write the PARENT task's doc (docTaskId via parentTaskId).
|
|
3376
|
+
appendDoc: async (markdown) => {
|
|
3377
|
+
if (!await appendTaskField(project, docTaskId, markdown))
|
|
3378
|
+
throw new Error(`Task ${docTaskId} not found \u2014 the task document was not updated.`);
|
|
3379
|
+
},
|
|
3380
|
+
emitArtifact: (kind, title, path2, data) => emit({ type: "artifact", kind, title, path: path2, data }),
|
|
3381
|
+
renderMockup: (title, html, css) => emit({ type: "mockup", id: nanoid2(10), title, html, css }),
|
|
3382
|
+
renderDiagram: (source, title) => emit({ type: "diagram", id: nanoid2(10), title, source }),
|
|
3383
|
+
// Reuse the existing (durable, taskId-addressed) ask_user_question round-trip:
|
|
3384
|
+
// emit the same event the SDK's AskUserQuestion uses and park the turn under its
|
|
3385
|
+
// id until the user submits/cancels — resolves with the answers map, or null.
|
|
3386
|
+
askForm: (questions) => awaitResolution(
|
|
3387
|
+
"ask_user_question",
|
|
3388
|
+
(id) => emit({ type: "ask_user_question", id, questions })
|
|
3389
|
+
),
|
|
3390
|
+
setPr: async (url, number) => {
|
|
3391
|
+
if (!await patchTaskFrontmatter(project, docTaskId, { prUrl: url, prNumber: number }))
|
|
3392
|
+
throw new Error(`Task ${docTaskId} not found \u2014 the PR was not recorded.`);
|
|
3393
|
+
},
|
|
3394
|
+
startRun: (name, command, url) => {
|
|
3395
|
+
const runId = nanoid2(10);
|
|
3396
|
+
outbox.enqueue({ t: "run", action: "start", runId, name, command, url });
|
|
3397
|
+
return runId;
|
|
3398
|
+
},
|
|
3399
|
+
runTests: (name, command) => {
|
|
3400
|
+
const runId = nanoid2(10);
|
|
3401
|
+
outbox.enqueue({ t: "run", action: "start", runId, name, command, kind: "test" });
|
|
3402
|
+
return runId;
|
|
3403
|
+
},
|
|
3404
|
+
stopRun: (runId) => outbox.enqueue({ t: "run", action: "stop", runId }),
|
|
3405
|
+
setSize: async (size) => {
|
|
3406
|
+
if (!await patchTaskFrontmatter(project, docTaskId, { size }))
|
|
3407
|
+
throw new Error(`Task ${docTaskId} not found \u2014 size unchanged.`);
|
|
3408
|
+
},
|
|
3409
|
+
// The test brief is task BODY content (Expected result / Affected areas) — write
|
|
3410
|
+
// it to the parent task's doc, like update_task_doc. Throw on a miss so the tool
|
|
3411
|
+
// reports the failure instead of a phantom success. Omitted fields are dropped
|
|
3412
|
+
// so an unset one isn't blanked.
|
|
3413
|
+
setTestBrief: async ({ expectedResult, affectedAreas }) => {
|
|
3414
|
+
const fields = {};
|
|
3415
|
+
if (expectedResult !== void 0) fields.expectedResult = expectedResult;
|
|
3416
|
+
if (affectedAreas !== void 0) fields.affectedAreas = affectedAreas;
|
|
3417
|
+
if (!await patchTaskFields(project, docTaskId, fields))
|
|
3418
|
+
throw new Error(`Task ${docTaskId} not found \u2014 the test brief was not updated.`);
|
|
3419
|
+
},
|
|
3420
|
+
// Only a fork tab has a renamable label; for a main session this is a no-op.
|
|
3421
|
+
// `config.forkOf` is the owning task id, `config.key` the fork's stream key.
|
|
3422
|
+
// Returns whether a rename actually happened so the tool can say so.
|
|
3423
|
+
renameSession: async (name) => {
|
|
3424
|
+
const label = name.trim();
|
|
3425
|
+
if (!config.forkOf || !label) return false;
|
|
3426
|
+
await patchForkLabel(project, config.forkOf, config.key, label);
|
|
3427
|
+
return true;
|
|
3428
|
+
},
|
|
3429
|
+
// Forward to the server, which routes to a connected preview overlay and replies
|
|
3430
|
+
// via a `resolve` (kind "devtools"). Same round-trip shape as the other callbacks.
|
|
3431
|
+
pageCommand: (action, args) => awaitResolution(
|
|
3432
|
+
"devtools",
|
|
3433
|
+
(id) => toServer({ t: "devtools", requestId: id, action, args }),
|
|
3434
|
+
{
|
|
3435
|
+
timeoutMs: 3e4,
|
|
3436
|
+
onTimeout: () => ({ ok: false, error: "Operator server unreachable \u2014 preview command timed out." })
|
|
3437
|
+
}
|
|
3438
|
+
)
|
|
3439
|
+
};
|
|
3440
|
+
}
|
|
3441
|
+
function orchestratorMcpContext() {
|
|
3442
|
+
return {
|
|
3443
|
+
project,
|
|
3444
|
+
// Only an orchestrator side-session fork has a renamable tab; the main project
|
|
3445
|
+
// agent's tab is fixed, so this is a no-op there (config.forkOf unset).
|
|
3446
|
+
renameSession: async (name) => {
|
|
3447
|
+
const label = name.trim();
|
|
3448
|
+
if (config.forkOf && label) await patchOrchestratorForkLabel(project, config.key, label);
|
|
3449
|
+
},
|
|
3450
|
+
createTask: async (title, summary, fields) => {
|
|
3451
|
+
const task = await createTask(project, { title, shortDescription: summary, fields });
|
|
3452
|
+
return task.id;
|
|
3453
|
+
},
|
|
3454
|
+
updateTask: async (id, patch) => {
|
|
3455
|
+
const ok3 = await updateTask(project, id, { ...patch, grooming: false });
|
|
3456
|
+
if (ok3 && patch.size) {
|
|
3457
|
+
const board = await readBoard(project);
|
|
3458
|
+
const task = board.tasks.find((t) => t.id === id);
|
|
3459
|
+
if (task?.status === "backlog") {
|
|
3460
|
+
const { devSpeed } = await readBoardConfig(project);
|
|
3461
|
+
const proposed = kickoffLane(task, devSpeed);
|
|
3462
|
+
if (proposed) await patchTaskFrontmatter(project, id, { proposedLane: proposed });
|
|
3463
|
+
}
|
|
3464
|
+
}
|
|
3465
|
+
return ok3;
|
|
3466
|
+
},
|
|
3467
|
+
listTasks: async () => {
|
|
3468
|
+
const board = await readBoard(project);
|
|
3469
|
+
return board.tasks.map((t) => ({
|
|
3470
|
+
id: t.id,
|
|
3471
|
+
title: t.title,
|
|
3472
|
+
status: t.status,
|
|
3473
|
+
summary: t.shortDescription ?? ""
|
|
3474
|
+
}));
|
|
3475
|
+
},
|
|
3476
|
+
getTask: async (id) => {
|
|
3477
|
+
const board = await readBoard(project);
|
|
3478
|
+
const task = board.tasks.find((t) => t.id === id);
|
|
3479
|
+
if (!task) return null;
|
|
3480
|
+
const doc = await readTaskDoc(project, id);
|
|
3481
|
+
return {
|
|
3482
|
+
id: task.id,
|
|
3483
|
+
title: task.title,
|
|
3484
|
+
summary: task.shortDescription ?? "",
|
|
3485
|
+
body: doc?.body ?? "",
|
|
3486
|
+
size: task.size,
|
|
3487
|
+
status: task.status
|
|
3488
|
+
};
|
|
3489
|
+
},
|
|
3490
|
+
foldTask: async (placeholderId, intoIds) => {
|
|
3491
|
+
outbox.enqueue({ t: "orchestrate", action: "fold", taskId: placeholderId, into: intoIds });
|
|
3492
|
+
return true;
|
|
3493
|
+
},
|
|
3494
|
+
deleteTask: async (id) => {
|
|
3495
|
+
outbox.enqueue({ t: "orchestrate", action: "delete", taskId: id });
|
|
3496
|
+
return true;
|
|
3497
|
+
},
|
|
3498
|
+
moveTask: async (id, toLane) => {
|
|
3499
|
+
outbox.enqueue({ t: "orchestrate", action: "move", taskId: id, toLane });
|
|
3500
|
+
return true;
|
|
3501
|
+
},
|
|
3502
|
+
startTask: async (id) => {
|
|
3503
|
+
const board = await readBoard(project);
|
|
3504
|
+
const task = board.tasks.find((t) => t.id === id);
|
|
3505
|
+
if (!task) return false;
|
|
3506
|
+
const { devSpeed } = await readBoardConfig(project);
|
|
3507
|
+
const toLane = kickoffLane(task, devSpeed);
|
|
3508
|
+
if (!toLane) return false;
|
|
3509
|
+
outbox.enqueue({ t: "orchestrate", action: "move", taskId: id, toLane });
|
|
3510
|
+
return true;
|
|
3511
|
+
}
|
|
3512
|
+
};
|
|
3513
|
+
}
|
|
3514
|
+
var mcp = config.kind === "orchestrator" ? buildOrchestratorMcp(orchestratorMcpContext()) : buildOperatorMcp(taskMcpContext(config.key));
|
|
3515
|
+
function buildSession() {
|
|
3516
|
+
if (process.env.OPERATOR_FAKE_AGENT) {
|
|
3517
|
+
const ctx = taskMcpContext(config.key);
|
|
3518
|
+
const orch = config.kind === "orchestrator" ? orchestratorMcpContext() : void 0;
|
|
3519
|
+
const dir = process.env.OPERATOR_FAKE_AGENT_DIR ?? join8(config.projectPath, ".operator", ".fake-agent");
|
|
3520
|
+
return new FakeAgentSession(
|
|
3521
|
+
{
|
|
3522
|
+
cwd: config.cwd,
|
|
3523
|
+
onEvent: emit,
|
|
3524
|
+
onSessionId: (id) => {
|
|
3525
|
+
sessionId = id;
|
|
3526
|
+
},
|
|
3527
|
+
proposeAdvance: ctx.proposeAdvance,
|
|
3528
|
+
requestClarification: ctx.requestClarification,
|
|
3529
|
+
runTests: config.kind === "task" ? ctx.runTests : void 0,
|
|
3530
|
+
appendDoc: config.kind === "task" ? ctx.appendDoc : async () => {
|
|
3531
|
+
},
|
|
3532
|
+
// Mirror onPermissionRequest: park the turn in the runner's pending map
|
|
3533
|
+
// under a fresh id — but emit the durable permission_request event here
|
|
3534
|
+
// (for the real session the SDK runtime emits it before the callback).
|
|
3535
|
+
requestPermission: (toolName, input) => awaitResolution(
|
|
3536
|
+
"permission",
|
|
3537
|
+
(id) => emit({ type: "permission_request", id, toolName, input })
|
|
3538
|
+
),
|
|
3539
|
+
...orch ? {
|
|
3540
|
+
orchestrateUpdateTask: (id, patch) => orch.updateTask(id, patch),
|
|
3541
|
+
orchestrateCreateTask: (title, summary, fields) => orch.createTask(title, summary, fields),
|
|
3542
|
+
orchestrateMoveTask: (id, toLane) => orch.moveTask(id, toLane),
|
|
3543
|
+
orchestrateListTasks: () => orch.listTasks(),
|
|
3544
|
+
orchestrateGetTask: (id) => orch.getTask(id),
|
|
3545
|
+
orchestrateFoldTask: (placeholderId, intoIds) => orch.foldTask(placeholderId, intoIds),
|
|
3546
|
+
orchestrateDeleteTask: (id) => orch.deleteTask(id)
|
|
3547
|
+
} : {}
|
|
3548
|
+
},
|
|
3549
|
+
fakeAgentScriptPath(dir, config.key)
|
|
3550
|
+
);
|
|
3551
|
+
}
|
|
3552
|
+
return realSession();
|
|
3553
|
+
}
|
|
3554
|
+
function realSession() {
|
|
3555
|
+
return new AgentSession({
|
|
3556
|
+
cwd: config.cwd,
|
|
3557
|
+
resumeSessionId: config.resumeSessionId,
|
|
3558
|
+
model: config.model,
|
|
3559
|
+
effort: config.effort,
|
|
3560
|
+
permissionMode: config.permissionMode,
|
|
3561
|
+
autonomous: config.autonomous,
|
|
3562
|
+
appendSystemPrompt: config.appendSystemPrompt,
|
|
3563
|
+
plugins: config.plugins,
|
|
3564
|
+
mcpServers: { operator: mcp },
|
|
3565
|
+
passthroughUserSettings: config.passthroughUserSettings,
|
|
3566
|
+
env: config.env,
|
|
3567
|
+
onEvent: emit,
|
|
3568
|
+
saveImage: (data, mediaType) => writeAsset(project, data, mediaType),
|
|
3569
|
+
onSessionId: async (id) => {
|
|
3570
|
+
sessionId = id;
|
|
3571
|
+
if (config.forkOf) {
|
|
3572
|
+
if (isOrchestratorKey(config.forkOf)) {
|
|
3573
|
+
await patchOrchestratorForkSession(project, config.key, id).catch(() => {
|
|
3574
|
+
});
|
|
3575
|
+
} else {
|
|
3576
|
+
await patchForkSession(project, config.forkOf, config.key, id).catch(() => {
|
|
3577
|
+
});
|
|
3578
|
+
}
|
|
3579
|
+
} else if (config.kind === "orchestrator") {
|
|
3580
|
+
await setProjectSettings(project, { orchestratorSessionId: id }).catch(() => {
|
|
3581
|
+
});
|
|
3582
|
+
} else if (!config.ephemeralSession) {
|
|
3583
|
+
await patchTaskFrontmatter(project, config.key, { sessionId: id }).catch(() => {
|
|
3584
|
+
});
|
|
3585
|
+
}
|
|
3586
|
+
},
|
|
3587
|
+
onPermissionRequest: (req) => new Promise((resolve) => {
|
|
3588
|
+
pending.set(req.id, { kind: "permission", resolve });
|
|
3589
|
+
}),
|
|
3590
|
+
// The runtime already emitted the `ask_user_question` event (carrying the
|
|
3591
|
+
// questions to the UI); here we just park the turn under its request id until
|
|
3592
|
+
// the user answers — mirroring onPermissionRequest. Resolves with the answers
|
|
3593
|
+
// map, or null if the user cancels.
|
|
3594
|
+
onAskUserQuestion: (req) => new Promise((resolve) => {
|
|
3595
|
+
pending.set(req.id, { kind: "ask_user_question", resolve });
|
|
3596
|
+
})
|
|
3597
|
+
});
|
|
3598
|
+
}
|
|
3599
|
+
var session = buildSession();
|
|
3600
|
+
function retireParkedPrompts() {
|
|
3601
|
+
const had = pending.size > 0;
|
|
3602
|
+
for (const [id, p] of pending) {
|
|
3603
|
+
pending.delete(id);
|
|
3604
|
+
if (p.kind === "permission") p.resolve({ allow: false, message: "Interrupted by the user." });
|
|
3605
|
+
else if (p.kind === "advance") p.resolve(false);
|
|
3606
|
+
else if (p.kind === "ask_user_question") p.resolve(null);
|
|
3607
|
+
else if (p.kind === "devtools") p.resolve({ ok: false, error: "interrupted" });
|
|
3608
|
+
else p.resolve("(interrupted by the user)");
|
|
3609
|
+
emit({ type: "prompt_dismissed", id });
|
|
3610
|
+
}
|
|
3611
|
+
return had;
|
|
3612
|
+
}
|
|
3613
|
+
function handle(msg) {
|
|
3614
|
+
lastActiveAt = Date.now();
|
|
3615
|
+
switch (msg.t) {
|
|
3616
|
+
case "send":
|
|
3617
|
+
void (async () => {
|
|
3618
|
+
if (await abortIfCwdGone()) return;
|
|
3619
|
+
emit({ type: "user_message", text: msg.text });
|
|
3620
|
+
try {
|
|
3621
|
+
await session.send(msg.text);
|
|
3622
|
+
} catch (err) {
|
|
3623
|
+
emit({
|
|
3624
|
+
type: "error",
|
|
3625
|
+
message: `The message could not start a turn: ${String(err).slice(0, 300)}. The runner is still alive \u2014 retry, or interrupt and resend.`
|
|
3626
|
+
});
|
|
3627
|
+
emit({ type: "status", state: "idle" });
|
|
3628
|
+
}
|
|
3629
|
+
})();
|
|
3630
|
+
break;
|
|
3631
|
+
case "interrupt":
|
|
3632
|
+
retireParkedPrompts();
|
|
3633
|
+
void session.interrupt();
|
|
3634
|
+
break;
|
|
3635
|
+
case "retry":
|
|
3636
|
+
void session.retry();
|
|
3637
|
+
break;
|
|
3638
|
+
case "rewind":
|
|
3639
|
+
void (async () => {
|
|
3640
|
+
if (retireParkedPrompts()) await session.interrupt();
|
|
3641
|
+
if (typeof msg.index === "number") {
|
|
3642
|
+
await truncateEventsToUserMessage(project, config.key, msg.index + 2).catch(() => {
|
|
3643
|
+
});
|
|
3644
|
+
}
|
|
3645
|
+
await session.rewind(msg.toUuid, msg.preview);
|
|
3646
|
+
})();
|
|
3647
|
+
break;
|
|
3648
|
+
case "setModel":
|
|
3649
|
+
void session.setModel(msg.model);
|
|
3650
|
+
break;
|
|
3651
|
+
case "setEffort":
|
|
3652
|
+
session.setEffort(msg.effort);
|
|
3653
|
+
break;
|
|
3654
|
+
case "setPermissionMode":
|
|
3655
|
+
void session.setPermissionMode(msg.mode);
|
|
3656
|
+
break;
|
|
3657
|
+
case "setAutonomous":
|
|
3658
|
+
session.setAutonomous(msg.value);
|
|
3659
|
+
break;
|
|
3660
|
+
case "setAuth": {
|
|
3661
|
+
accountId = msg.accountId;
|
|
3662
|
+
if (msg.token !== authToken) {
|
|
3663
|
+
authToken = msg.token;
|
|
3664
|
+
session.setAuthToken(msg.token);
|
|
3665
|
+
}
|
|
3666
|
+
if (limitStalled) {
|
|
3667
|
+
limitStalled = false;
|
|
3668
|
+
throttle = null;
|
|
3669
|
+
void session.retry();
|
|
3670
|
+
}
|
|
3671
|
+
break;
|
|
3672
|
+
}
|
|
3673
|
+
case "resolve": {
|
|
3674
|
+
const p = pending.get(msg.requestId);
|
|
3675
|
+
if (!p) return;
|
|
3676
|
+
pending.delete(msg.requestId);
|
|
3677
|
+
if (p.kind === "permission") {
|
|
3678
|
+
const v = msg.value;
|
|
3679
|
+
p.resolve({ allow: v.allow, message: v.message });
|
|
3680
|
+
} else {
|
|
3681
|
+
p.resolve(msg.value);
|
|
3682
|
+
}
|
|
3683
|
+
break;
|
|
3684
|
+
}
|
|
3685
|
+
case "ack":
|
|
3686
|
+
outbox.ack(msg.id);
|
|
3687
|
+
break;
|
|
3688
|
+
case "dismiss": {
|
|
3689
|
+
const feedback = msg.feedback?.trim() || void 0;
|
|
3690
|
+
if (feedback) emit({ type: "user_message", text: feedback });
|
|
3691
|
+
let consumed = false;
|
|
3692
|
+
for (const [id, p] of pending) {
|
|
3693
|
+
pending.delete(id);
|
|
3694
|
+
if (p.kind === "permission") {
|
|
3695
|
+
p.resolve({ allow: false, message: feedback ?? "Dismissed \u2014 user took a different action." });
|
|
3696
|
+
consumed ||= !!feedback;
|
|
3697
|
+
} else if (p.kind === "advance") {
|
|
3698
|
+
p.resolve(feedback ? advanceFeedback(feedback) : false);
|
|
3699
|
+
consumed ||= !!feedback;
|
|
3700
|
+
} else if (p.kind === "ask_user_question") {
|
|
3701
|
+
p.resolve(null);
|
|
3702
|
+
} else if (p.kind === "devtools") {
|
|
3703
|
+
p.resolve({ ok: false, error: "dismissed" });
|
|
3704
|
+
} else {
|
|
3705
|
+
p.resolve(feedback ?? "(dismissed \u2014 user responded with a message or moved the task instead)");
|
|
3706
|
+
consumed ||= !!feedback;
|
|
3707
|
+
}
|
|
3708
|
+
emit({ type: "prompt_dismissed", id });
|
|
3709
|
+
}
|
|
3710
|
+
if (feedback && !consumed) void session.send(feedback);
|
|
3711
|
+
break;
|
|
3712
|
+
}
|
|
3713
|
+
case "shutdown":
|
|
3714
|
+
void shutdown();
|
|
3715
|
+
break;
|
|
3716
|
+
}
|
|
3717
|
+
}
|
|
3718
|
+
async function start() {
|
|
3719
|
+
await fs6.mkdir(runnersDir(project.path), { recursive: true });
|
|
3720
|
+
const sPath = sockPath(project.path, config.key);
|
|
3721
|
+
await fs6.rm(sPath, { force: true }).catch(() => {
|
|
3722
|
+
});
|
|
3723
|
+
const server = net.createServer((conn) => {
|
|
3724
|
+
conns.add(conn);
|
|
3725
|
+
onConnected();
|
|
3726
|
+
writeMsg(conn, {
|
|
3727
|
+
t: "snapshot",
|
|
3728
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
3729
|
+
pending: [...pending.keys()],
|
|
3730
|
+
sessionId,
|
|
3731
|
+
state: sessionState,
|
|
3732
|
+
throttle,
|
|
3733
|
+
accountId,
|
|
3734
|
+
limitStalled,
|
|
3735
|
+
commands: slashCommands
|
|
3736
|
+
});
|
|
3737
|
+
outbox.replay((m) => writeMsg(conn, m));
|
|
3738
|
+
attachLineReader(conn, (line) => {
|
|
3739
|
+
try {
|
|
3740
|
+
handle(JSON.parse(line));
|
|
3741
|
+
} catch {
|
|
3742
|
+
}
|
|
3743
|
+
});
|
|
3744
|
+
const drop = () => {
|
|
3745
|
+
conns.delete(conn);
|
|
3746
|
+
onDisconnected();
|
|
3747
|
+
};
|
|
3748
|
+
conn.on("close", drop);
|
|
3749
|
+
conn.on("error", drop);
|
|
3750
|
+
});
|
|
3751
|
+
const idleTimer = setInterval(() => {
|
|
3752
|
+
if (throttle?.resetsAt) return;
|
|
3753
|
+
if (pending.size > 0) return;
|
|
3754
|
+
if (disconnectedAt != null && Date.now() - disconnectedAt >= IDLE_MS) {
|
|
3755
|
+
void shutdown(`idle: no Operator connection for ${Math.round((Date.now() - disconnectedAt) / 1e3)}s`);
|
|
3756
|
+
return;
|
|
3757
|
+
}
|
|
3758
|
+
if (sessionState === "running") return;
|
|
3759
|
+
if (disconnectedAt == null && Date.now() - lastActiveAt >= SESSION_IDLE_MS) {
|
|
3760
|
+
void shutdown(`idle: session inactive for ${Math.round((Date.now() - lastActiveAt) / 1e3)}s`);
|
|
3761
|
+
}
|
|
3762
|
+
}, 3e4);
|
|
3763
|
+
idleTimer.unref();
|
|
3764
|
+
server.listen(sPath, async () => {
|
|
3765
|
+
const meta = {
|
|
3766
|
+
key: config.key,
|
|
3767
|
+
pid: process.pid,
|
|
3768
|
+
projectId: config.projectId,
|
|
3769
|
+
projectPath: config.projectPath,
|
|
3770
|
+
sock: sPath,
|
|
3771
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3772
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
3773
|
+
// Echo the spawning server's code version so a later server lifetime can
|
|
3774
|
+
// detect this runner is on stale code and recycle it once idle.
|
|
3775
|
+
codeVersion: config.codeVersion
|
|
3776
|
+
};
|
|
3777
|
+
await fs6.writeFile(metaPath(project.path, config.key), JSON.stringify(meta, null, 2));
|
|
3778
|
+
});
|
|
3779
|
+
}
|
|
3780
|
+
var delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
3781
|
+
var shuttingDown = false;
|
|
3782
|
+
async function shutdown(reason = "shutdown") {
|
|
3783
|
+
if (shuttingDown) return;
|
|
3784
|
+
shuttingDown = true;
|
|
3785
|
+
console.log(`[runner ${config.key}] stopping: ${reason}`);
|
|
3786
|
+
await Promise.race([session.stop().catch(() => {
|
|
3787
|
+
}), delay(1e4)]);
|
|
3788
|
+
await fs6.rm(sockPath(project.path, config.key), { force: true }).catch(() => {
|
|
3789
|
+
});
|
|
3790
|
+
await fs6.rm(metaPath(project.path, config.key), { force: true }).catch(() => {
|
|
3791
|
+
});
|
|
3792
|
+
process.exit(0);
|
|
3793
|
+
}
|
|
3794
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
3795
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
3796
|
+
start().catch((err) => {
|
|
3797
|
+
console.error("[runner] fatal", err);
|
|
3798
|
+
process.exit(1);
|
|
3799
|
+
});
|