@tea-agent/loop-agent 0.40.0-next.12 → 0.40.0-next.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +1 -1
  3. package/dist/build-stamp.json +3 -3
  4. package/dist/cli/command-definitions.js +9 -5
  5. package/dist/cli/program.js +38 -3
  6. package/dist/commands/task-advance.js +23 -1
  7. package/dist/commands/task-source-prepare.js +5 -0
  8. package/dist/commands/task-status.js +1 -0
  9. package/dist/shared/operator/capabilities.js +9 -1
  10. package/dist/worker/console/chat/assistant-content.js +7 -0
  11. package/dist/worker/console/chat/chat-event-store.js +82 -11
  12. package/dist/worker/console/chat/goal-round-driver.js +72 -0
  13. package/dist/worker/console/chat/goals.js +300 -0
  14. package/dist/worker/console/chat/pi-runtime.js +140 -4
  15. package/dist/worker/console/chat/repo-browser.js +94 -12
  16. package/dist/worker/console/chat/routes.js +482 -29
  17. package/dist/worker/console/chat/session-catalog.js +48 -0
  18. package/dist/worker/console/chat/session-store.js +27 -18
  19. package/dist/worker/console/chat/shortcuts.js +7 -0
  20. package/dist/worker/console/chat/turn-process.js +32 -3
  21. package/dist/worker/console/operator-actions.js +16 -2
  22. package/dist/worker/console/operator-selection.js +6 -2
  23. package/dist/worker/console/pi-plugins.catalog.json +73658 -0
  24. package/dist/worker/console/pi-plugins.js +100 -0
  25. package/dist/worker/console/routes.js +221 -6
  26. package/dist/worker/console/server.js +172 -42
  27. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-C_hkwt2J.js → abnfDiagram-N423BO3Z-Wj-fsQlk.js} +1 -1
  28. package/dist/worker/console/static/assets/{arc-s2Q6r96B.js → arc-CkXT-vzQ.js} +1 -1
  29. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CtgsrwU1.js → architectureDiagram-T3A2C74G-xlfnojWU.js} +1 -1
  30. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-DZ-A7EiJ.js → blockDiagram-VBNYF7ZC-BilahvuB.js} +1 -1
  31. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-D2qv0Nyo.js → c4Diagram-5PPSVZJV-DqtAVhjz.js} +1 -1
  32. package/dist/worker/console/static/assets/channel-SnQ_u-Td.js +1 -0
  33. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-D-UYpSaW.js → chunk-2GRJ4B5K-pAqQXZ5y.js} +1 -1
  34. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-DAS3jrrt.js → chunk-2Q5K7J3B-BEWxeq2r.js} +1 -1
  35. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-CSbuRcGB.js → chunk-5RXB4S5H-CvlbGDTp.js} +1 -1
  36. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BDH1ZO40.js → chunk-5VM5RSS4-D0uGd9-f.js} +1 -1
  37. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Dc2cqQIb.js → chunk-6Q2QTUOP-DSdWIteg.js} +1 -1
  38. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CkZuemIW.js → chunk-GF5L2VYU-Dweg7f0v.js} +1 -1
  39. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-URwjQnte.js → chunk-JWPE2WC7-BpZKI_k7.js} +1 -1
  40. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-04RHylJK.js → chunk-KBJHAD2P-IPLknC4m.js} +1 -1
  41. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-CvQX56Hh.js → chunk-RYQCIY6F-E93k9lVI.js} +1 -1
  42. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-dUF7qura.js → chunk-XXDRQBXY-BrESwkPq.js} +1 -1
  43. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-SGM51NVY.js +1 -0
  44. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-SGM51NVY.js +1 -0
  45. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-COwbfxJE.js → cose-bilkent-JH36ORCC-DmFHvJkB.js} +1 -1
  46. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-CaBEJekQ.js → cynefin-VYW2F7L2-CDigXiSL.js} +1 -1
  47. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-COWUBdet.js → cynefinDiagram-MW4NZA55-B0zhrbaA.js} +1 -1
  48. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BLxVqTn4.js → dagre-VZM6K2ZE-DrKybVxm.js} +1 -1
  49. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-WL0yKfEQ.js → diagram-7IWD3JNH-CeUrnbFk.js} +1 -1
  50. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-DaVkKCle.js → diagram-B4RE2ZJO-BJyunUMi.js} +1 -1
  51. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DdTgnRGf.js → diagram-LBJQPF4R-a5TBP73u.js} +1 -1
  52. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-PG2Axo6A.js → diagram-Q27KOJAE-ChRgBURJ.js} +1 -1
  53. package/dist/worker/console/static/assets/{diagram-UB23O5K3-6vuQzlzI.js → diagram-UB23O5K3-CqCnPRSk.js} +1 -1
  54. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BpXeBz-g.js → ebnfDiagram-BXEA7PRR-BI-UD9_S.js} +1 -1
  55. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-PxtUnaa1.js → erDiagram-JOGREHBK-CDLvvkYZ.js} +1 -1
  56. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-B--Qgvs1.js → flowDiagram-UKHOOZJN-Heoo4afj.js} +1 -1
  57. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-BIfQCgtx.js → ganttDiagram-PKOTCBZU-kFpOitHd.js} +1 -1
  58. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-BRaX_E4j.js → gitGraphDiagram-DS77QQ5N-FvyZ9WAY.js} +1 -1
  59. package/dist/worker/console/static/assets/index-C80SO8y0.js +437 -0
  60. package/dist/worker/console/static/assets/index-CmJCHmJP.css +1 -0
  61. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-Cd93r4Ab.js → infoDiagram-6WML65LV-HBzlwtgI.js} +1 -1
  62. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-C4c80iHt.js → ishikawaDiagram-WSZJBQD7-C9Iim5sm.js} +1 -1
  63. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-Ch5ajf3s.js → journeyDiagram-NVQOT4AX-CJwJTg0S.js} +1 -1
  64. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-DbpyW1Ut.js → kanban-definition-27J2QSJJ-CZNJ7S4q.js} +1 -1
  65. package/dist/worker/console/static/assets/{linear-BHVjBnTW.js → linear-RZ0AnGi8.js} +1 -1
  66. package/dist/worker/console/static/assets/{mermaid.core-DO70kJ34.js → mermaid.core-EYSdm-nd.js} +5 -5
  67. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-_P44Zpvs.js → mindmap-definition-FAOFIHXS-D0OLECFN.js} +1 -1
  68. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-Bgi9FmEd.js → pegDiagram-VL7TDLO6-CPTvGbh7.js} +1 -1
  69. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-C9InDfJn.js → pieDiagram-7S7Q4E2Y-Ddc1aDea.js} +1 -1
  70. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-DtKrUouv.js → quadrantDiagram-CIZ2JOQS-CWCTCdAt.js} +1 -1
  71. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-zgAD_6Yo.js → railroadDiagram-AXF67PYL-CfsaBL8E.js} +1 -1
  72. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CQNO6kj4.js → requirementDiagram-LRYGKXZP-BH2rqGf6.js} +1 -1
  73. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-knKu111P.js → sankeyDiagram-W5VNT64P-BZgJ1Til.js} +1 -1
  74. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-D6tshiQ6.js → sequenceDiagram-SI44F4Z6-DmPr-o-y.js} +1 -1
  75. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-BMIWJcir.js → sizeCapture-X5ZJPWSS-CF6HzDh_.js} +1 -1
  76. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-BP9wRDqc.js → stateDiagram-OKZ733FA-DhS8N_B9.js} +1 -1
  77. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-N56zxwsk.js +1 -0
  78. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DEkM5Dhe.js → swimlanes-SLNWSIFB-CYNzoiaD.js} +2 -2
  79. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-o47kGgVt.js +8 -0
  80. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-ByBKsL35.js → timeline-definition-Z64GVDOM-QBICF5qH.js} +1 -1
  81. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-waFbNCUy.js → vennDiagram-T6HMQDX7-fquBvWcf.js} +1 -1
  82. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-1ljiY0eZ.js → wardleyDiagram-T6FBY63Y-BYaJLlSd.js} +1 -1
  83. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-B4ZLHr_L.js → xychartDiagram-ELKLHX3M--ePe_JCJ.js} +1 -1
  84. package/dist/worker/console/static/index.html +2 -2
  85. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +95 -25
  86. package/dist/worker/console/static-src/operator-chat/compaction-message.js +35 -1
  87. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +3 -1
  88. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +24 -19
  89. package/dist/worker/console/static-src/operator-chat/useChatStream.js +0 -11
  90. package/dist/worker/console/static-src/operator-chat/useChatThread.js +8 -1
  91. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +34 -4
  92. package/dist/worker/console/static-src/operator-chat/workspace-reorder.js +18 -0
  93. package/dist/worker/console/static-src/shell/useWorkspaces.js +150 -34
  94. package/dist/worker/console/workspace-context.js +28 -3
  95. package/dist/worker/console/workspace-registry.js +333 -60
  96. package/dist/worker/observe/static/inspect-bootstrap.d.ts +8 -0
  97. package/dist/worker/observe/static/inspect-bootstrap.js +57 -0
  98. package/dist/worker/observe/static/inspect-workspace.js +54 -10
  99. package/dist/worker/observe/static/operator-chrome.css +23 -4
  100. package/dist/worker/observe/static/operator-chrome.js +6 -2
  101. package/dist/worker/observe/static/styles.css +78 -25
  102. package/dist/worker/observe/static/views/dag-inspector.js +2 -1
  103. package/dist/worker/observe/static/views/dags.js +7 -5
  104. package/dist/workflows/dag/init-hybrid.js +3 -3
  105. package/docs/templates/backend-test-dag.json +3 -3
  106. package/package.json +1 -1
  107. package/skills/loop-agent/references/command-reference.md +14 -0
  108. package/dist/worker/console/static/assets/channel-BluaMT3-.js +0 -1
  109. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-H-ZpSafr.js +0 -1
  110. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-H-ZpSafr.js +0 -1
  111. package/dist/worker/console/static/assets/index-Crrgl8Mx.css +0 -1
  112. package/dist/worker/console/static/assets/index-vPsp-idE.js +0 -437
  113. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-ClEhytQG.js +0 -1
  114. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DPg5IMuu.js +0 -8
@@ -0,0 +1,300 @@
1
+ import { randomUUID } from "node:crypto";
2
+ export const DEFAULT_GOAL_MAX_ROUNDS = 32;
3
+ function cloneGoal(goal) {
4
+ return structuredClone(goal);
5
+ }
6
+ export function normalizeGoalObjective(value) {
7
+ if (typeof value !== "string")
8
+ throw new Error("objective must be a string");
9
+ const objective = value.replace(/\r\n/g, "\n").trim();
10
+ if (!objective)
11
+ throw new Error("objective must not be empty");
12
+ if (objective.length > 8_000)
13
+ throw new Error("objective exceeds 8000 characters");
14
+ return objective;
15
+ }
16
+ export function isExplicitGoalCreationRequest(text) {
17
+ const normalized = text.trim().toLowerCase();
18
+ return (/^\/goal(?:\s|$)/i.test(normalized) ||
19
+ /(创建|新建|启动|开启|设定|设置).{0,10}(持续目标|goal)/i.test(normalized) ||
20
+ /(create|start|set up|enable).{0,16}\bgoal\b/i.test(normalized));
21
+ }
22
+ export function parseGoalSnapshot(value) {
23
+ if (!value || typeof value !== "object")
24
+ return undefined;
25
+ const item = value;
26
+ if (item.schemaVersion !== 1 ||
27
+ typeof item.goalId !== "string" ||
28
+ !item.goalId ||
29
+ !Number.isSafeInteger(item.revision) ||
30
+ (item.revision ?? 0) < 1 ||
31
+ typeof item.objective !== "string" ||
32
+ !item.objective.trim() ||
33
+ !["active", "paused", "blocked", "complete"].includes(item.phase) ||
34
+ !Number.isSafeInteger(item.round) ||
35
+ (item.round ?? -1) < 0 ||
36
+ !Number.isSafeInteger(item.maxRounds) ||
37
+ (item.maxRounds ?? 0) < 1 ||
38
+ typeof item.createdAt !== "string" ||
39
+ typeof item.updatedAt !== "string") {
40
+ return undefined;
41
+ }
42
+ return {
43
+ schemaVersion: 1,
44
+ goalId: item.goalId,
45
+ revision: item.revision,
46
+ objective: item.objective.trim(),
47
+ phase: item.phase,
48
+ round: item.round,
49
+ maxRounds: item.maxRounds,
50
+ createdAt: item.createdAt,
51
+ updatedAt: item.updatedAt,
52
+ ...(typeof item.lastTurnId === "string" && item.lastTurnId
53
+ ? { lastTurnId: item.lastTurnId }
54
+ : {}),
55
+ ...(typeof item.blocker === "string" && item.blocker.trim()
56
+ ? { blocker: item.blocker.trim() }
57
+ : {}),
58
+ };
59
+ }
60
+ export function projectCurrentGoal(events) {
61
+ let current = null;
62
+ for (const event of events) {
63
+ if (event.kind === "goal_cleared")
64
+ current = null;
65
+ if (event.kind === "goal_changed") {
66
+ const parsed = parseGoalSnapshot(event.data.goal);
67
+ if (parsed)
68
+ current = parsed;
69
+ }
70
+ }
71
+ return current ? cloneGoal(current) : null;
72
+ }
73
+ export function buildGoalCompactionInstructions(rawInstructions, goal) {
74
+ const reminder = goal
75
+ ? [
76
+ "Preserve this durable Goal snapshot in the compaction summary. Do not mark it complete or change its phase unless the Goal tool already did so:",
77
+ JSON.stringify(goal),
78
+ ].join("\n")
79
+ : undefined;
80
+ return [rawInstructions?.trim(), reminder]
81
+ .filter((item) => Boolean(item))
82
+ .join("\n\n") || undefined;
83
+ }
84
+ export function buildGoalContinuationContext(goal) {
85
+ if (!goal)
86
+ return undefined;
87
+ return [
88
+ "<current_goal>",
89
+ "This is the durable cross-turn Goal for this session. Continue working toward its objective without broadening user authorization.",
90
+ JSON.stringify(goal),
91
+ "Use get_goal to refresh it. Use update_goal with status=complete only when the objective is genuinely achieved and no required work remains; use blocked only when safe progress cannot continue.",
92
+ "</current_goal>",
93
+ ].join("\n");
94
+ }
95
+ export class GoalRegistry {
96
+ events;
97
+ armed = new Set();
98
+ modelCreateGrants = new Set();
99
+ constructor(events) {
100
+ this.events = events;
101
+ }
102
+ get(sessionId) {
103
+ const goal = projectCurrentGoal(this.events.snapshot(sessionId));
104
+ return goal ? { ...goal, armed: this.armed.has(sessionId) } : null;
105
+ }
106
+ startTurn(sessionId, userText) {
107
+ if (isExplicitGoalCreationRequest(userText))
108
+ this.modelCreateGrants.add(sessionId);
109
+ else
110
+ this.modelCreateGrants.delete(sessionId);
111
+ }
112
+ consumeModelCreateGrant(sessionId) {
113
+ const granted = this.modelCreateGrants.has(sessionId);
114
+ this.modelCreateGrants.delete(sessionId);
115
+ return granted;
116
+ }
117
+ create(input) {
118
+ let objective;
119
+ try {
120
+ objective = normalizeGoalObjective(input.objective);
121
+ }
122
+ catch (error) {
123
+ return {
124
+ ok: false,
125
+ code: "GOAL_INVALID_INPUT",
126
+ message: error instanceof Error ? error.message : String(error),
127
+ };
128
+ }
129
+ const existing = this.get(input.sessionId);
130
+ if (existing && existing.phase !== "complete") {
131
+ return {
132
+ ok: false,
133
+ code: "GOAL_ALREADY_EXISTS",
134
+ message: "an unfinished Goal already exists for this session",
135
+ goal: existing,
136
+ };
137
+ }
138
+ const maxRounds = Number.isSafeInteger(input.maxRounds)
139
+ ? Math.max(1, Math.min(128, input.maxRounds))
140
+ : DEFAULT_GOAL_MAX_ROUNDS;
141
+ const now = new Date().toISOString();
142
+ const goal = {
143
+ schemaVersion: 1,
144
+ goalId: randomUUID(),
145
+ revision: 1,
146
+ objective,
147
+ phase: "active",
148
+ round: 0,
149
+ maxRounds,
150
+ createdAt: now,
151
+ updatedAt: now,
152
+ };
153
+ this.events.append(input.sessionId, input.turnId ?? `${input.sessionId}:goal`, { kind: "goal_changed", data: { goal } });
154
+ if (input.arm !== false)
155
+ this.armed.add(input.sessionId);
156
+ return { ok: true, goal: { ...goal, armed: this.armed.has(input.sessionId) } };
157
+ }
158
+ update(input) {
159
+ const current = this.get(input.sessionId);
160
+ if (!current)
161
+ return { ok: false, code: "GOAL_NOT_FOUND", message: "Goal not found" };
162
+ if (current.goalId !== input.goalId)
163
+ return {
164
+ ok: false,
165
+ code: "GOAL_ID_CONFLICT",
166
+ message: "Goal id does not match the current session Goal",
167
+ goal: current,
168
+ };
169
+ if (current.revision !== input.expectedRevision)
170
+ return {
171
+ ok: false,
172
+ code: "GOAL_REVISION_CONFLICT",
173
+ message: `Goal revision changed: expected ${input.expectedRevision}, current ${current.revision}`,
174
+ goal: current,
175
+ };
176
+ let objective = current.objective;
177
+ if (input.objective !== undefined) {
178
+ try {
179
+ objective = normalizeGoalObjective(input.objective);
180
+ }
181
+ catch (error) {
182
+ return {
183
+ ok: false,
184
+ code: "GOAL_INVALID_INPUT",
185
+ message: error instanceof Error ? error.message : String(error),
186
+ goal: current,
187
+ };
188
+ }
189
+ }
190
+ const phase = input.phase ?? current.phase;
191
+ const blocker = typeof input.blocker === "string" ? input.blocker.trim().slice(0, 2_000) : "";
192
+ if (phase === "blocked" && !blocker && !current.blocker) {
193
+ return {
194
+ ok: false,
195
+ code: "GOAL_INVALID_INPUT",
196
+ message: "blocked Goal requires a concrete blocker",
197
+ goal: current,
198
+ };
199
+ }
200
+ const next = {
201
+ ...current,
202
+ revision: current.revision + 1,
203
+ objective,
204
+ phase,
205
+ updatedAt: new Date().toISOString(),
206
+ ...(phase === "blocked" && blocker
207
+ ? { blocker }
208
+ : current.blocker && phase === "blocked"
209
+ ? { blocker: current.blocker }
210
+ : {}),
211
+ };
212
+ delete next.armed;
213
+ if (phase !== "blocked")
214
+ delete next.blocker;
215
+ this.events.append(input.sessionId, input.turnId ?? current.lastTurnId ?? `${input.sessionId}:goal`, { kind: "goal_changed", data: { goal: next } });
216
+ if (phase !== "active" || input.arm === false)
217
+ this.armed.delete(input.sessionId);
218
+ else if (input.arm === true)
219
+ this.armed.add(input.sessionId);
220
+ return { ok: true, goal: { ...next, armed: this.armed.has(input.sessionId) } };
221
+ }
222
+ beginRound(input) {
223
+ const current = this.get(input.sessionId);
224
+ if (!current)
225
+ return { ok: false, code: "GOAL_NOT_FOUND", message: "Goal not found" };
226
+ if (current.round >= current.maxRounds) {
227
+ this.armed.delete(input.sessionId);
228
+ return {
229
+ ok: false,
230
+ code: "GOAL_ROUND_LIMIT",
231
+ message: `Goal reached its ${current.maxRounds}-round safety limit`,
232
+ goal: { ...current, armed: false },
233
+ };
234
+ }
235
+ if (current.goalId !== input.goalId)
236
+ return {
237
+ ok: false,
238
+ code: "GOAL_ID_CONFLICT",
239
+ message: "Goal id changed before the next round started",
240
+ goal: current,
241
+ };
242
+ if (current.revision !== input.expectedRevision)
243
+ return {
244
+ ok: false,
245
+ code: "GOAL_REVISION_CONFLICT",
246
+ message: `Goal revision changed: expected ${input.expectedRevision}, current ${current.revision}`,
247
+ goal: current,
248
+ };
249
+ const next = {
250
+ ...current,
251
+ revision: current.revision + 1,
252
+ round: current.round + 1,
253
+ lastTurnId: input.turnId,
254
+ updatedAt: new Date().toISOString(),
255
+ };
256
+ delete next.armed;
257
+ this.events.append(input.sessionId, input.turnId, {
258
+ kind: "goal_changed",
259
+ data: { goal: next },
260
+ });
261
+ return { ok: true, goal: { ...next, armed: this.armed.has(input.sessionId) } };
262
+ }
263
+ clear(input) {
264
+ const current = this.get(input.sessionId);
265
+ if (!current)
266
+ return { ok: false, code: "GOAL_NOT_FOUND", message: "Goal not found" };
267
+ if (current.goalId !== input.goalId || current.revision !== input.expectedRevision) {
268
+ return {
269
+ ok: false,
270
+ code: current.goalId !== input.goalId
271
+ ? "GOAL_ID_CONFLICT"
272
+ : "GOAL_REVISION_CONFLICT",
273
+ message: "Goal changed before it could be cleared",
274
+ goal: current,
275
+ };
276
+ }
277
+ this.armed.delete(input.sessionId);
278
+ this.events.append(input.sessionId, input.turnId ?? current.lastTurnId ?? `${input.sessionId}:goal`, { kind: "goal_cleared", data: {} });
279
+ return { ok: true, goal: { ...current, armed: false } };
280
+ }
281
+ arm(sessionId) {
282
+ const goal = this.get(sessionId);
283
+ if (!goal || goal.phase !== "active" || goal.round >= goal.maxRounds)
284
+ return goal;
285
+ this.armed.add(sessionId);
286
+ return { ...goal, armed: true };
287
+ }
288
+ disarm(sessionId) {
289
+ this.armed.delete(sessionId);
290
+ const goal = this.get(sessionId);
291
+ return goal ? { ...goal, armed: false } : null;
292
+ }
293
+ isArmed(sessionId) {
294
+ return this.armed.has(sessionId);
295
+ }
296
+ clearRuntime(sessionId) {
297
+ this.armed.delete(sessionId);
298
+ this.modelCreateGrants.delete(sessionId);
299
+ }
300
+ }
@@ -42,6 +42,7 @@ import { resolveDefaultChatModel, resolveLowChatModel, } from "./model-resolver.
42
42
  import { filterActiveInterviewTools } from "../interview/tools.js";
43
43
  import { RUNTIME_CONTEXT_TEXT_MAX, redactRuntimeText, } from "./runtime-context.js";
44
44
  import { buildTodoContinuationContext, } from "./todos.js";
45
+ import { buildGoalContinuationContext, } from "./goals.js";
45
46
  /**
46
47
  * Base system prompt fragment every General Operator Chat session receives.
47
48
  *
@@ -66,9 +67,9 @@ export const OPERATOR_CHAT_SYSTEM_PROMPT_BASE = [
66
67
  "You are General Operator Chat.",
67
68
  "Use operator tools first (dagReport, dagRerunPlan, dagRerun). Pi read/write/edit/bash/grep/find/ls are available; obey repository AGENTS.md. apply_patch, full-tools, shell and coding-chat are denied. Prefer safe-read/safe-grep for probes.",
68
69
  "No harness and asked to init: `loop-agent init instructions --repo-root .`, then `loop-agent init --repo-root . --profile full --merge`; verify with doctor, inspect and docs audit. Updates: `loop-agent init upgrade --repo-root . --json` until stable; `loop-agent init check-update` is read-only.",
69
- "Long DAGs: prepareDagExecution → runDag → operationId. Never foreground-Bash task advance, tail/head pipes or nohup/Start-Process/start; do not end on accepted/queued/running or an operationId — NOT completion. Supervise operationGet/status/dagReport/dagDoctor/operationWait with 60s → 90s → 120s → 180s backoff; reset to 60s on state change; 30-60s re-checks when stall suspected. Every operationWait continuation MUST use afterSeq=previous nextSeq (prefer recommendedNextCall); never reuse an older cursor. Heartbeats are not meaningful progress.",
70
- "On failure consume terminal runSummary first. If progress exists, wait; else dagReport then dagRerunPlan(fromNode=primaryFailure.nodeId); eligible → dagRerun (node rerun only: reruns the failed subgraph, keeps prior successes). If plan ineligible (unsafe-shell subgraph, binding/workspace drift): Worker Failed → workerTaskRetry; standalone → standaloneTaskRerun after contract adopt (regenerates the whole task, not node continue). Do not call dagResume. A run-failed task without an open gate is terminal: do not re-advance a run-failed task. Before run start, two identical lifecycleState/blocker/revision/DAG-hash observations mean no progress: stop and report after one bounded probe; never loop identical bash/read/grep. Auto-approve only bounded writeSet inside allowedPaths, outside forbiddenPaths, with structured verification.",
71
- "Iterate toward the goal in bounded rounds: first summarize the previous round's error in one line (wrong fix, wrong node, or environment), fix its root cause — not the symptom — then rerun the failed node with a fresh plan. Never resubmit an unchanged fix; after two rounds failing the same way, stop and report evidence and remaining routes.",
70
+ "Long DAGs: prepareDagExecution → runDag → operationId. Never foreground-Bash task advance, tail/head pipes or nohup/Start-Process/start; do not end on accepted/queued/running or an operationId — NOT completion. Supervise operationGet/status/dagReport/dagDoctor/operationWait with 60s → 90s → 120s → 180s backoff; reset to 60s on state change; 30-60s re-checks when stall suspected. Every operationWait continuation MUST use afterSeq=previous nextSeq (recommendedNextCall); never reuse older cursors. Heartbeats: not meaningful progress. Run id 20260829-slug ≠ task id 2026-08-29-slug.",
71
+ "On failure consume terminal runSummary first. If progress exists, wait; else dagReport then dagRerunPlan(fromNode=primaryFailure.nodeId); eligible → dagRerun (node rerun only: reruns the failed subgraph, keeps prior successes). If plan ineligible (unsafe-shell subgraph, binding/workspace drift): Worker Failed → workerTaskRetry; standalone → standaloneTaskRerun after contract adopt (regenerates the whole task, not node continue). Do not call dagResume. Run-failed task without open gate is terminal: do not re-advance a run-failed task. Before run start, two identical lifecycleState/blocker/revision/DAG-hash observations mean no progress: stop and report after one bounded probe; never loop identical bash/read/grep. Auto-approve only bounded writeSet inside allowedPaths, outside forbiddenPaths, with structured verification.",
72
+ "Iterate toward the goal in bounded rounds: first summarize the previous round's error in one line, fix its root cause — not the symptom — then rerun the failed node with a fresh plan. Never resubmit an unchanged fix; after two rounds failing the same way, stop and report evidence and remaining routes.",
72
73
  "Continue until success, user stop, or no safe recovery remains; then stop and report failure/evidence. Never create a new task for a transient failure or replace repair-pi with direct edits.",
73
74
  ].join("\n");
74
75
  /** Compose the inspectable system prompt actually injected into Operator Chat. */
@@ -259,6 +260,43 @@ export function createTodoCompactionContextExtension(options) {
259
260
  },
260
261
  };
261
262
  }
263
+ /** Goal survives compaction and Turn boundaries; unlike Todo it is not reset
264
+ * at turn_start. The extension only injects after a successful compaction so
265
+ * ordinary contexts still rely on the model-callable get_goal source. */
266
+ export function createGoalCompactionContextExtension(options) {
267
+ return {
268
+ name: "operator-chat-goal-compaction-context",
269
+ hidden: true,
270
+ factory: (api) => {
271
+ let compacted = false;
272
+ api.on("session_compact", () => {
273
+ compacted = true;
274
+ });
275
+ api.on("context", (rawEvent) => {
276
+ if (!compacted)
277
+ return undefined;
278
+ const goal = options.registry.get(options.sessionId);
279
+ const context = buildGoalContinuationContext(goal);
280
+ if (!context)
281
+ return undefined;
282
+ const messages = Array.isArray(rawEvent.messages) ? rawEvent.messages : [];
283
+ return {
284
+ messages: [
285
+ ...messages,
286
+ {
287
+ role: "custom",
288
+ customType: "operator-chat-goal-context",
289
+ content: context,
290
+ display: false,
291
+ details: { goal },
292
+ timestamp: Date.now(),
293
+ },
294
+ ],
295
+ };
296
+ });
297
+ },
298
+ };
299
+ }
262
300
  /**
263
301
  * Build Pi custom ToolDefinition objects for every whitelisted operator action.
264
302
  * Each tool's execute() routes into the operator action dispatcher (Gate 3).
@@ -408,6 +446,80 @@ async function buildTodoWriteTool(options) {
408
446
  },
409
447
  });
410
448
  }
449
+ const GET_GOAL_DESCRIPTION = "Read the current session's durable cross-turn Goal, including revision, status, round limit, and whether this Console process is authorized to continue it.";
450
+ const CREATE_GOAL_DESCRIPTION = "Create a durable cross-turn Goal only when the user explicitly asks to start or create a Goal. Never silently upgrade an ordinary request. A session may have only one unfinished Goal.";
451
+ const UPDATE_GOAL_DESCRIPTION = "Finish or block the current durable Goal using its exact goalId and revision. Set complete only when the objective is genuinely achieved and no required work remains. Set blocked only when safe progress cannot continue; include the blocker.";
452
+ async function buildGoalTools(options) {
453
+ const [{ Type }, { defineTool }] = await Promise.all([
454
+ import("typebox"),
455
+ import("@earendil-works/pi-coding-agent"),
456
+ ]);
457
+ const textResult = (value) => ({
458
+ content: [{ type: "text", text: JSON.stringify(value) }],
459
+ details: value,
460
+ });
461
+ return [
462
+ defineTool({
463
+ name: "get_goal",
464
+ label: "Get goal",
465
+ description: GET_GOAL_DESCRIPTION,
466
+ promptSnippet: GET_GOAL_DESCRIPTION,
467
+ parameters: Type.Object({}, { additionalProperties: false }),
468
+ async execute() {
469
+ return textResult({ goal: options.registry.get(options.sessionId) });
470
+ },
471
+ }),
472
+ defineTool({
473
+ name: "create_goal",
474
+ label: "Create goal",
475
+ description: CREATE_GOAL_DESCRIPTION,
476
+ promptSnippet: CREATE_GOAL_DESCRIPTION,
477
+ parameters: Type.Object({
478
+ objective: Type.String({
479
+ description: "Concrete final objective and completion standard explicitly requested by the user.",
480
+ }),
481
+ }, { additionalProperties: false }),
482
+ async execute(_toolCallId, params) {
483
+ if (!options.registry.consumeModelCreateGrant(options.sessionId)) {
484
+ return textResult({
485
+ ok: false,
486
+ code: "GOAL_EXPLICIT_REQUEST_REQUIRED",
487
+ message: "create_goal requires an explicit user request to create or start a Goal",
488
+ });
489
+ }
490
+ return textResult(options.registry.create({
491
+ sessionId: options.sessionId,
492
+ objective: params.objective,
493
+ }));
494
+ },
495
+ }),
496
+ defineTool({
497
+ name: "update_goal",
498
+ label: "Update goal",
499
+ description: UPDATE_GOAL_DESCRIPTION,
500
+ promptSnippet: UPDATE_GOAL_DESCRIPTION,
501
+ parameters: Type.Object({
502
+ goalId: Type.String(),
503
+ revision: Type.Number({ minimum: 1 }),
504
+ status: Type.Union([
505
+ Type.Literal("complete"),
506
+ Type.Literal("blocked"),
507
+ ]),
508
+ blocker: Type.Optional(Type.String()),
509
+ }, { additionalProperties: false }),
510
+ async execute(_toolCallId, params) {
511
+ return textResult(options.registry.update({
512
+ sessionId: options.sessionId,
513
+ goalId: params.goalId,
514
+ expectedRevision: params.revision,
515
+ phase: params.status,
516
+ blocker: params.blocker,
517
+ arm: false,
518
+ }));
519
+ },
520
+ }),
521
+ ];
522
+ }
411
523
  /**
412
524
  * Build the safe explore custom tools (roadmap M0-A / G16): safe-read,
413
525
  * safe-grep, gitStatus, gitDiff. These replace the bare SDK bash/read/grep so
@@ -900,7 +1012,17 @@ export class ConsolePiRuntime {
900
1012
  const pressureExtension = sessionId
901
1013
  ? this.createContextPressureInlineExtension(sessionId)
902
1014
  : undefined;
903
- const extraFactories = [todoContextExtension, pressureExtension].filter((item) => Boolean(item));
1015
+ const goalContextExtension = sessionId && this.options.goalRegistry
1016
+ ? createGoalCompactionContextExtension({
1017
+ sessionId,
1018
+ registry: this.options.goalRegistry,
1019
+ })
1020
+ : undefined;
1021
+ const extraFactories = [
1022
+ todoContextExtension,
1023
+ goalContextExtension,
1024
+ pressureExtension,
1025
+ ].filter((item) => Boolean(item));
904
1026
  return {
905
1027
  ...base,
906
1028
  ...(extraFactories.length > 0
@@ -1318,11 +1440,15 @@ export class ConsolePiRuntime {
1318
1440
  const todoTool = this.options.todoRegistry
1319
1441
  ? await buildTodoWriteTool({ sessionId, registry: this.options.todoRegistry })
1320
1442
  : undefined;
1443
+ const goalTools = this.options.goalRegistry
1444
+ ? await buildGoalTools({ sessionId, registry: this.options.goalRegistry })
1445
+ : [];
1321
1446
  return [
1322
1447
  ...operatorCustomTools,
1323
1448
  ...exploreCustomTools,
1324
1449
  ...(questionTool ? [questionTool] : []),
1325
1450
  ...(todoTool ? [todoTool] : []),
1451
+ ...goalTools,
1326
1452
  ];
1327
1453
  }
1328
1454
  loadOperatorCustomTools() {
@@ -1612,6 +1738,9 @@ export class ConsolePiRuntime {
1612
1738
  this.reloadInFlight.delete(sessionId);
1613
1739
  }
1614
1740
  this.bumpRevision(sessionId);
1741
+ // Reload replaces the effective resource/session context. Persisted Goal
1742
+ // remains visible, but continuation authorization must be renewed by user.
1743
+ this.options.goalRegistry?.disarm(sessionId);
1615
1744
  return { ok: true, revision: this.revisions.get(sessionId) ?? 0 };
1616
1745
  }
1617
1746
  /**
@@ -2057,6 +2186,9 @@ export class ConsolePiRuntime {
2057
2186
  if (this.sessions.has(sessionId)) {
2058
2187
  return { ok: true, handle: await this.snapshotActive(sessionId) };
2059
2188
  }
2189
+ // A newly materialized Pi session must never inherit process-local Goal
2190
+ // activation from an earlier disposed runtime instance.
2191
+ this.options.goalRegistry?.disarm(sessionId);
2060
2192
  if (!init.sessionFile) {
2061
2193
  return {
2062
2194
  ok: false,
@@ -2296,6 +2428,9 @@ export class ConsolePiRuntime {
2296
2428
  if (options?.turnId && this.options.todoRegistry) {
2297
2429
  this.options.todoRegistry.startTurn(sessionId, options.turnId);
2298
2430
  }
2431
+ if (this.options.goalRegistry) {
2432
+ this.options.goalRegistry.startTurn(sessionId, text);
2433
+ }
2299
2434
  // AC-FIX-002: if Stop already fired before readiness, never start model work.
2300
2435
  if (options?.signal?.aborted) {
2301
2436
  return;
@@ -2606,6 +2741,7 @@ export class ConsolePiRuntime {
2606
2741
  dispose(sessionId) {
2607
2742
  // Mark disposed first so a late materialization never registers ready.
2608
2743
  this.disposedSessions.add(sessionId);
2744
+ this.options.goalRegistry?.disarm(sessionId);
2609
2745
  this.sessionInit.delete(sessionId);
2610
2746
  this.compactionControllers.delete(sessionId);
2611
2747
  this.siftStates.delete(sessionId);
@@ -4,8 +4,12 @@ import { lstat, open, readdir } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { guardReadPath, isSensitivePath, scrubSecrets, } from "./explore-tools.js";
6
6
  const TREE_LIMIT = 500;
7
- const READ_LIMIT = 256 * 1024;
8
- const RESPONSE_LIMIT = 128 * 1024;
7
+ /** DSH-style bounded source read: large text files return this leading slice
8
+ * instead of being rejected solely because the complete file is larger. */
9
+ export const TEXT_PREVIEW_LIMIT = 512 * 1024;
10
+ /** Full-file ceiling for the sandboxed HTML preview route and its assets. */
11
+ export const HTML_PREVIEW_ASSET_LIMIT = 20 * 1024 * 1024;
12
+ export const REPO_HTML_PREVIEW_ROUTE_PREFIX = "/api/operator/v1/chat/repo/html/";
9
13
  const SKIP_DIRS = new Set([
10
14
  ".git",
11
15
  ".harness",
@@ -32,6 +36,45 @@ export class RepoBrowserError extends Error {
32
36
  function guardedCode(code) {
33
37
  return code === "PATH_OUTSIDE_REPO" ? "OUT_OF_REPO" : code;
34
38
  }
39
+ /** Match the path-encoded HTML preview route. Keeping workspace + file path in
40
+ * the URL path lets browser-relative CSS/image references retain their scope. */
41
+ export function matchRepoHtmlPreviewRoute(pathname) {
42
+ if (!pathname.startsWith(REPO_HTML_PREVIEW_ROUTE_PREFIX))
43
+ return null;
44
+ const rest = pathname.slice(REPO_HTML_PREVIEW_ROUTE_PREFIX.length);
45
+ const [workspaceId = "", ...pathSegments] = rest.split("/");
46
+ return { workspaceId, rawPath: pathSegments.join("/") };
47
+ }
48
+ export function repoHtmlAssetContentType(rawPath) {
49
+ const extension = path.extname(rawPath).toLowerCase();
50
+ const types = {
51
+ ".html": "text/html; charset=utf-8",
52
+ ".htm": "text/html; charset=utf-8",
53
+ ".css": "text/css; charset=utf-8",
54
+ ".js": "text/javascript; charset=utf-8",
55
+ ".mjs": "text/javascript; charset=utf-8",
56
+ ".json": "application/json; charset=utf-8",
57
+ ".txt": "text/plain; charset=utf-8",
58
+ ".svg": "image/svg+xml",
59
+ ".png": "image/png",
60
+ ".jpg": "image/jpeg",
61
+ ".jpeg": "image/jpeg",
62
+ ".gif": "image/gif",
63
+ ".webp": "image/webp",
64
+ ".avif": "image/avif",
65
+ ".bmp": "image/bmp",
66
+ ".ico": "image/x-icon",
67
+ ".woff": "font/woff",
68
+ ".woff2": "font/woff2",
69
+ ".ttf": "font/ttf",
70
+ ".otf": "font/otf",
71
+ ".mp3": "audio/mpeg",
72
+ ".wav": "audio/wav",
73
+ ".mp4": "video/mp4",
74
+ ".webm": "video/webm",
75
+ };
76
+ return types[extension] ?? "application/octet-stream";
77
+ }
35
78
  export async function listRepoDirectory(repoRoot, rawPath) {
36
79
  const root = rawPath === "";
37
80
  const guard = await guardReadPath(repoRoot, root ? "." : rawPath);
@@ -92,8 +135,6 @@ export async function readRepoPreview(repoRoot, rawPath) {
92
135
  const info = await handle.stat();
93
136
  if (!info.isFile())
94
137
  throw new RepoBrowserError(400, "NOT_A_FILE", "path is not a regular file");
95
- if (info.size > READ_LIMIT)
96
- throw new RepoBrowserError(413, "TOO_LARGE", `file exceeds ${READ_LIMIT} byte preview limit`);
97
138
  const rechecked = await guardReadPath(repoRoot, rawPath);
98
139
  if (!rechecked.ok)
99
140
  throw new RepoBrowserError(403, guardedCode(rechecked.code), "file path changed while it was being opened");
@@ -103,9 +144,10 @@ export async function readRepoPreview(repoRoot, rawPath) {
103
144
  current.ino !== info.ino)
104
145
  throw new RepoBrowserError(403, "OUT_OF_REPO", "file identity changed while it was being opened");
105
146
  size = info.size;
106
- bytes = Buffer.alloc(size);
107
- const bytesRead = size
108
- ? (await handle.read(bytes, 0, size, 0)).bytesRead
147
+ const readSize = Math.min(size, TEXT_PREVIEW_LIMIT);
148
+ bytes = Buffer.alloc(readSize);
149
+ const bytesRead = readSize
150
+ ? (await handle.read(bytes, 0, readSize, 0)).bytesRead
109
151
  : 0;
110
152
  bytes = bytes.subarray(0, bytesRead);
111
153
  }
@@ -114,17 +156,14 @@ export async function readRepoPreview(repoRoot, rawPath) {
114
156
  }
115
157
  if (bytes.includes(0))
116
158
  throw new RepoBrowserError(415, "BINARY_UNSUPPORTED", "binary files cannot be previewed");
117
- let end = Math.min(bytes.length, RESPONSE_LIMIT);
118
- while (end > 0 && end < bytes.length && (bytes[end] & 0xc0) === 0x80)
119
- end -= 1;
120
- const content = scrubSecrets(bytes.subarray(0, end).toString("utf8")).scrubbed;
159
+ const content = scrubSecrets(bytes.toString("utf8")).scrubbed;
121
160
  return {
122
161
  path: guard.repoRelative,
123
162
  kind: "text",
124
163
  content,
125
164
  language: path.extname(guard.repoRelative).slice(1).toLowerCase() || "text",
126
165
  bytes: size,
127
- truncated: bytes.length > RESPONSE_LIMIT,
166
+ truncated: size > bytes.length,
128
167
  sha256: createHash("sha256").update(bytes).digest("hex"),
129
168
  };
130
169
  }
@@ -138,3 +177,46 @@ export async function readRepoPreview(repoRoot, rawPath) {
138
177
  throw error;
139
178
  }
140
179
  }
180
+ /** Read one complete file for the sandboxed HTML iframe. Unlike source reads,
181
+ * this route needs complete HTML/CSS/image bytes so documents and relative
182
+ * assets render correctly; the separate 20 MiB cap keeps memory bounded. */
183
+ export async function readRepoHtmlAsset(repoRoot, rawPath) {
184
+ const guard = await guardReadPath(repoRoot, rawPath);
185
+ if (!guard.ok)
186
+ throw new RepoBrowserError(403, guardedCode(guard.code), guard.message);
187
+ try {
188
+ const handle = await open(guard.abs, constants.O_RDONLY | constants.O_NOFOLLOW);
189
+ try {
190
+ const info = await handle.stat();
191
+ if (!info.isFile())
192
+ throw new RepoBrowserError(400, "NOT_A_FILE", "path is not a regular file");
193
+ if (info.size > HTML_PREVIEW_ASSET_LIMIT)
194
+ throw new RepoBrowserError(413, "TOO_LARGE", `file exceeds ${HTML_PREVIEW_ASSET_LIMIT} byte HTML preview limit`);
195
+ const rechecked = await guardReadPath(repoRoot, rawPath);
196
+ if (!rechecked.ok)
197
+ throw new RepoBrowserError(403, guardedCode(rechecked.code), "file path changed while it was being opened");
198
+ const current = await lstat(rechecked.abs);
199
+ if (current.isSymbolicLink() ||
200
+ current.dev !== info.dev ||
201
+ current.ino !== info.ino)
202
+ throw new RepoBrowserError(403, "OUT_OF_REPO", "file identity changed while it was being opened");
203
+ return {
204
+ body: await handle.readFile(),
205
+ contentType: repoHtmlAssetContentType(guard.repoRelative),
206
+ bytes: info.size,
207
+ };
208
+ }
209
+ finally {
210
+ await handle.close();
211
+ }
212
+ }
213
+ catch (error) {
214
+ if (error instanceof RepoBrowserError)
215
+ throw error;
216
+ if (error.code === "ENOENT")
217
+ throw new RepoBrowserError(404, "NOT_FOUND", "file does not exist");
218
+ if (error.code === "ELOOP")
219
+ throw new RepoBrowserError(403, "OUT_OF_REPO", "symbolic links are not previewable");
220
+ throw error;
221
+ }
222
+ }