@tea-agent/loop-agent 0.29.2 → 0.29.3

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 (39) hide show
  1. package/CHANGELOG.md +29 -27
  2. package/bin/agent-worker.js +0 -0
  3. package/dist/commands/client-recovery.js +2 -10
  4. package/dist/commands/init.js +1 -1
  5. package/dist/executors/pi-executor.js +5 -3
  6. package/dist/executors/pi-playwright-cli-tool.js +9 -14
  7. package/dist/executors/pi-sdk-executor.js +16 -0
  8. package/dist/shared/operator/capabilities.js +9 -9
  9. package/dist/shared/pi-retry-settings.js +23 -0
  10. package/dist/worker/console/chat/pi-runtime.js +167 -41
  11. package/dist/worker/console/chat/routes.js +21 -2
  12. package/dist/worker/console/chat/runtime-context.js +50 -8
  13. package/dist/worker/console/chat/tool-preview.js +90 -0
  14. package/dist/worker/console/chat/turn-process.js +178 -0
  15. package/dist/worker/console/chat/usage.js +144 -16
  16. package/dist/worker/console/night-aux-ticker.js +141 -0
  17. package/dist/worker/console/operation-runner.js +21 -4
  18. package/dist/worker/console/operator-user-error.js +12 -0
  19. package/dist/worker/console/recovery-cta.js +6 -6
  20. package/dist/worker/console/routes.js +61 -0
  21. package/dist/worker/console/server.js +7 -0
  22. package/dist/worker/console/static/assets/index-D9gnJn_l.js +29 -0
  23. package/dist/worker/console/static/assets/index-rajoXwkM.css +1 -0
  24. package/dist/worker/console/static/index.html +2 -2
  25. package/dist/worker/console/static-src/night-prepare-result.js +118 -0
  26. package/dist/worker/scheduler/clock-install/win32-schtasks.js +70 -15
  27. package/dist/workflows/dag/frontend-test-case-checklist.js +3 -3
  28. package/dist/workflows/dag/init-hybrid.js +21 -12
  29. package/docs/templates/frontend-test-case-checklist.md +1 -1
  30. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +1 -1
  31. package/docs/templates/frontend-test-dag.json +1 -1
  32. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
  33. package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
  34. package/docs/templates/init-managed-agents.md +6 -3
  35. package/package.json +1 -1
  36. package/skills/playwright-cli/SKILL.md +1 -1
  37. package/skills/playwright-cli-case-generator/SKILL.md +1 -1
  38. package/dist/worker/console/static/assets/index-Cwx-ZVEQ.js +0 -29
  39. package/dist/worker/console/static/assets/index-Yyn3ynVv.css +0 -1
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Turn grouping for Operator Chat inline process UX (pi-web ProcessDetails mind).
3
+ */
4
+ /**
5
+ * Restore chat bubbles + tool cards from durable session messages.
6
+ * Tool rows often appear before the final assistant `message_end` message;
7
+ * synthetic turn ids bridge that gap for later grouping.
8
+ */
9
+ export function hydrateSessionThread(raw) {
10
+ const messages = [];
11
+ const toolCalls = [];
12
+ let turnId;
13
+ let pendingTools = [];
14
+ const flushPendingToAssistant = (assistantId) => {
15
+ for (const tool of pendingTools) {
16
+ if (!tool.assistantMessageId)
17
+ tool.assistantMessageId = assistantId;
18
+ if (!tool.turnId)
19
+ tool.turnId = turnId;
20
+ }
21
+ pendingTools = [];
22
+ };
23
+ for (const item of raw) {
24
+ const createdAt = Date.parse(item.at) || Date.now();
25
+ if (item.role === "user") {
26
+ turnId = `restored-${item.id}`;
27
+ pendingTools = [];
28
+ messages.push({
29
+ id: item.id,
30
+ role: "user",
31
+ text: item.text,
32
+ createdAt,
33
+ });
34
+ continue;
35
+ }
36
+ if (item.role === "assistant") {
37
+ if (!turnId)
38
+ turnId = `restored-${item.id}`;
39
+ messages.push({
40
+ id: item.id,
41
+ role: "assistant",
42
+ text: item.text,
43
+ createdAt,
44
+ });
45
+ flushPendingToAssistant(item.id);
46
+ continue;
47
+ }
48
+ if (item.role === "tool") {
49
+ if (!turnId)
50
+ turnId = `restored-tool-${item.id}`;
51
+ const entry = {
52
+ id: item.toolCallId || item.id,
53
+ toolName: item.toolName || "tool",
54
+ status: item.isError === true ? "error" : "ok",
55
+ resultText: item.text,
56
+ result: item.text,
57
+ createdAt,
58
+ finishedAt: createdAt,
59
+ turnId,
60
+ };
61
+ toolCalls.push(entry);
62
+ pendingTools.push(entry);
63
+ }
64
+ }
65
+ return { messages, toolCalls };
66
+ }
67
+ /**
68
+ * Build user → process → answer groups for the message timeline.
69
+ */
70
+ export function buildTurnProcessGroups(input) {
71
+ const visible = input.messages.filter((m) => m.role === "user" || m.role === "assistant");
72
+ const groups = [];
73
+ let current = null;
74
+ const startGroup = (key) => {
75
+ const group = {
76
+ key,
77
+ assistants: [],
78
+ tools: [],
79
+ live: false,
80
+ };
81
+ groups.push(group);
82
+ return group;
83
+ };
84
+ for (const message of visible) {
85
+ if (message.role === "user") {
86
+ current = startGroup(`user-${message.id}`);
87
+ current.user = message;
88
+ continue;
89
+ }
90
+ if (!current)
91
+ current = startGroup(`assistant-${message.id}`);
92
+ current.assistants.push(message);
93
+ }
94
+ if (groups.length === 0 && input.toolCalls.length > 0) {
95
+ groups.push({
96
+ key: "orphan-tools",
97
+ assistants: [],
98
+ tools: [],
99
+ live: false,
100
+ });
101
+ }
102
+ const assigned = new Set();
103
+ const assistantToGroup = new Map();
104
+ for (const group of groups) {
105
+ for (const assistant of group.assistants) {
106
+ assistantToGroup.set(assistant.id, group);
107
+ }
108
+ }
109
+ for (const tool of input.toolCalls) {
110
+ if (tool.assistantMessageId) {
111
+ const group = assistantToGroup.get(tool.assistantMessageId);
112
+ if (group) {
113
+ group.tools.push(tool);
114
+ assigned.add(tool.id);
115
+ continue;
116
+ }
117
+ }
118
+ }
119
+ for (const tool of input.toolCalls) {
120
+ if (assigned.has(tool.id))
121
+ continue;
122
+ if (tool.turnId) {
123
+ const byTurn = groups.find((group) => group.tools.some((t) => t.turnId === tool.turnId) ||
124
+ group.key === `turn-${tool.turnId}` ||
125
+ (group.user && `restored-${group.user.id}` === tool.turnId));
126
+ if (byTurn) {
127
+ byTurn.tools.push(tool);
128
+ assigned.add(tool.id);
129
+ continue;
130
+ }
131
+ // Match restored turn id `restored-<userOrAssistantId>`
132
+ const restored = groups.find((group) => tool.turnId === `restored-${group.user?.id}` ||
133
+ group.assistants.some((a) => tool.turnId === `restored-${a.id}`));
134
+ if (restored) {
135
+ restored.tools.push(tool);
136
+ assigned.add(tool.id);
137
+ continue;
138
+ }
139
+ }
140
+ }
141
+ for (const tool of input.toolCalls) {
142
+ if (assigned.has(tool.id))
143
+ continue;
144
+ const windowGroup = groups.find((group, index) => {
145
+ const start = group.user?.createdAt ?? group.assistants[0]?.createdAt ?? 0;
146
+ const next = groups[index + 1];
147
+ const end = next?.user?.createdAt ?? Number.POSITIVE_INFINITY;
148
+ return tool.createdAt >= start && tool.createdAt < end;
149
+ });
150
+ if (windowGroup) {
151
+ windowGroup.tools.push(tool);
152
+ assigned.add(tool.id);
153
+ }
154
+ else if (groups.length > 0) {
155
+ groups[groups.length - 1].tools.push(tool);
156
+ assigned.add(tool.id);
157
+ }
158
+ }
159
+ for (const group of groups) {
160
+ group.tools.sort((a, b) => a.createdAt - b.createdAt);
161
+ group.live =
162
+ input.streaming &&
163
+ input.activeAssistantId !== null &&
164
+ group.assistants.some((a) => a.id === input.activeAssistantId);
165
+ }
166
+ return groups;
167
+ }
168
+ export function processGroupSummary(tools) {
169
+ const count = tools.length;
170
+ const errorCount = tools.filter((t) => t.status === "error").length;
171
+ const runningCount = tools.filter((t) => t.status === "running").length;
172
+ const parts = [`执行过程 · ${count} 工具`];
173
+ if (errorCount > 0)
174
+ parts.push(`${errorCount} 出错`);
175
+ else if (runningCount > 0)
176
+ parts.push("运行中");
177
+ return { count, errorCount, runningCount, label: parts.join(" · ") };
178
+ }
@@ -2,36 +2,164 @@ function record(value) {
2
2
  return value && typeof value === "object" ? value : undefined;
3
3
  }
4
4
  function numberAt(source, keys) {
5
- for (const key of keys)
6
- if (typeof source[key] === "number" && Number.isFinite(source[key]))
5
+ for (const key of keys) {
6
+ if (typeof source[key] === "number" && Number.isFinite(source[key])) {
7
7
  return Math.max(0, source[key]);
8
+ }
9
+ }
8
10
  return undefined;
9
11
  }
12
+ function costAt(source) {
13
+ const direct = numberAt(source, ["cost"]);
14
+ if (direct !== undefined)
15
+ return direct;
16
+ const nested = record(source.cost);
17
+ if (!nested)
18
+ return undefined;
19
+ return numberAt(nested, ["total", "amount"]);
20
+ }
21
+ /**
22
+ * Extract usage from Pi SDK / OpenAI-shaped events.
23
+ * Pi assistant usage uses `{ input, output, cacheRead, cacheWrite, totalTokens, cost }`.
24
+ */
10
25
  export function extractUsageSample(value) {
11
26
  const event = record(value);
12
27
  if (!event)
13
28
  return undefined;
14
29
  const message = record(event.message);
15
- const source = record(event.usage) ?? record(message?.usage) ?? record(event.tokenUsage);
30
+ const source = record(event.usage) ??
31
+ record(message?.usage) ??
32
+ record(event.tokenUsage);
16
33
  if (!source)
17
34
  return undefined;
18
- const inputTokens = numberAt(source, ["input_tokens", "inputTokens", "prompt_tokens", "promptTokens"]);
19
- const outputTokens = numberAt(source, ["output_tokens", "outputTokens", "completion_tokens", "completionTokens"]);
20
- const totalTokens = numberAt(source, ["total_tokens", "totalTokens"]) ?? (inputTokens !== undefined || outputTokens !== undefined ? (inputTokens ?? 0) + (outputTokens ?? 0) : undefined);
21
- if (inputTokens === undefined && outputTokens === undefined && totalTokens === undefined)
35
+ const inputTokens = numberAt(source, [
36
+ "input",
37
+ "input_tokens",
38
+ "inputTokens",
39
+ "prompt_tokens",
40
+ "promptTokens",
41
+ ]);
42
+ const outputTokens = numberAt(source, [
43
+ "output",
44
+ "output_tokens",
45
+ "outputTokens",
46
+ "completion_tokens",
47
+ "completionTokens",
48
+ ]);
49
+ const cacheReadTokens = numberAt(source, [
50
+ "cacheRead",
51
+ "cache_read",
52
+ "cache_read_input_tokens",
53
+ "cacheReadTokens",
54
+ ]);
55
+ const cacheWriteTokens = numberAt(source, [
56
+ "cacheWrite",
57
+ "cache_write",
58
+ "cache_creation_input_tokens",
59
+ "cacheWriteTokens",
60
+ ]);
61
+ const partsSum = (inputTokens ?? 0) +
62
+ (outputTokens ?? 0) +
63
+ (cacheReadTokens ?? 0) +
64
+ (cacheWriteTokens ?? 0);
65
+ const hasParts = inputTokens !== undefined ||
66
+ outputTokens !== undefined ||
67
+ cacheReadTokens !== undefined ||
68
+ cacheWriteTokens !== undefined;
69
+ const totalTokens = numberAt(source, ["total_tokens", "totalTokens"]) ??
70
+ (hasParts ? partsSum : undefined);
71
+ const cost = costAt(source);
72
+ if (inputTokens === undefined &&
73
+ outputTokens === undefined &&
74
+ cacheReadTokens === undefined &&
75
+ cacheWriteTokens === undefined &&
76
+ totalTokens === undefined &&
77
+ cost === undefined) {
22
78
  return undefined;
23
- const responseKey = [event.responseId, message?.id, source.responseId].find((item) => typeof item === "string");
24
- return { inputTokens, outputTokens, totalTokens, ...(responseKey ? { responseKey } : {}) };
79
+ }
80
+ const responseKey = [
81
+ event.responseId,
82
+ message?.id,
83
+ source.responseId,
84
+ ].find((item) => typeof item === "string");
85
+ return {
86
+ ...(inputTokens !== undefined ? { inputTokens } : {}),
87
+ ...(outputTokens !== undefined ? { outputTokens } : {}),
88
+ ...(cacheReadTokens !== undefined ? { cacheReadTokens } : {}),
89
+ ...(cacheWriteTokens !== undefined ? { cacheWriteTokens } : {}),
90
+ ...(totalTokens !== undefined ? { totalTokens } : {}),
91
+ ...(cost !== undefined ? { cost } : {}),
92
+ ...(responseKey ? { responseKey } : {}),
93
+ };
25
94
  }
26
95
  export function aggregateUsage(samples) {
27
96
  const keyed = new Map();
28
97
  const anonymous = [];
29
- for (const sample of samples)
30
- sample.responseKey ? keyed.set(sample.responseKey, sample) : anonymous.push(sample);
98
+ for (const sample of samples) {
99
+ if (sample.responseKey)
100
+ keyed.set(sample.responseKey, sample);
101
+ else
102
+ anonymous.push(sample);
103
+ }
31
104
  const selected = [...keyed.values(), ...anonymous];
32
- return selected.reduce((sum, sample) => ({
33
- inputTokens: sum.inputTokens + (sample.inputTokens ?? 0), outputTokens: sum.outputTokens + (sample.outputTokens ?? 0), totalTokens: sum.totalTokens + (sample.totalTokens ?? ((sample.inputTokens ?? 0) + (sample.outputTokens ?? 0))),
34
- cost: sample.cost === undefined ? sum.cost : (sum.cost ?? 0) + sample.cost,
35
- estimated: Boolean(sum.estimated || sample.estimated),
36
- }), { inputTokens: 0, outputTokens: 0, totalTokens: 0 });
105
+ return selected.reduce((sum, sample) => {
106
+ const input = sample.inputTokens ?? 0;
107
+ const output = sample.outputTokens ?? 0;
108
+ const cacheRead = sample.cacheReadTokens ?? 0;
109
+ const cacheWrite = sample.cacheWriteTokens ?? 0;
110
+ const total = sample.totalTokens ?? input + output + cacheRead + cacheWrite;
111
+ return {
112
+ inputTokens: sum.inputTokens + input,
113
+ outputTokens: sum.outputTokens + output,
114
+ cacheReadTokens: sum.cacheReadTokens + cacheRead,
115
+ cacheWriteTokens: sum.cacheWriteTokens + cacheWrite,
116
+ totalTokens: sum.totalTokens + total,
117
+ cost: sample.cost === undefined
118
+ ? sum.cost
119
+ : (sum.cost ?? 0) + sample.cost,
120
+ estimated: Boolean(sum.estimated || sample.estimated),
121
+ };
122
+ }, {
123
+ inputTokens: 0,
124
+ outputTokens: 0,
125
+ cacheReadTokens: 0,
126
+ cacheWriteTokens: 0,
127
+ totalTokens: 0,
128
+ });
129
+ }
130
+ /** Compact token count aligned with pi-web SessionInfoBar / ChatInput. */
131
+ export function formatTokenCount(tokens) {
132
+ if (tokens >= 1_000_000)
133
+ return `${(tokens / 1_000_000).toFixed(1)}M`;
134
+ if (tokens >= 1_000)
135
+ return `${(tokens / 1_000).toFixed(1)}k`;
136
+ return tokens.toLocaleString();
137
+ }
138
+ /**
139
+ * Status-line usage text aligned with pi-web SessionInfoBar chips:
140
+ * `12.3k in · 1.2k out · 8.0k cache R · $0.01`
141
+ */
142
+ export function formatUsageStatusLine(usage) {
143
+ const parts = [];
144
+ const input = usage.inputTokens ?? 0;
145
+ const output = usage.outputTokens ?? 0;
146
+ const cacheRead = usage.cacheReadTokens ?? 0;
147
+ const cacheWrite = usage.cacheWriteTokens ?? 0;
148
+ const cost = usage.cost ?? 0;
149
+ if (input > 0)
150
+ parts.push(`${formatTokenCount(input)} in`);
151
+ if (output > 0)
152
+ parts.push(`${formatTokenCount(output)} out`);
153
+ if (cacheRead > 0)
154
+ parts.push(`${formatTokenCount(cacheRead)} cache R`);
155
+ if (cacheWrite > 0)
156
+ parts.push(`${formatTokenCount(cacheWrite)} cache W`);
157
+ if (cost > 0) {
158
+ parts.push(cost >= 0.01 ? `$${cost.toFixed(2)}` : "<$0.01");
159
+ }
160
+ if (parts.length === 0) {
161
+ const total = usage.totalTokens ?? 0;
162
+ return total > 0 ? `${formatTokenCount(total)} tokens` : "—";
163
+ }
164
+ return parts.join(" · ");
37
165
  }
@@ -0,0 +1,141 @@
1
+ import { resolveSiblingAgentWorkerBin } from "./sibling-controller.js";
2
+ import { DEFAULT_CLOCK_INTERVAL_SEC } from "../scheduler/clock.js";
3
+ export const NIGHT_AUX_TICKER_DISCLAIMER = "仅在本 Console 进程开着时有效;关掉 Console 或睡觉后不会 tick。过夜无人值守请安装 OS clock:agent-worker scheduler clock install --repo .";
4
+ /**
5
+ * Optional Console-foreground scheduler ticker.
6
+ * Spawns sibling `agent-worker scheduler tick` so long dispatches do not block
7
+ * the Console HTTP event loop the way an in-process await would.
8
+ */
9
+ export class NightAuxTicker {
10
+ deps;
11
+ enabled = false;
12
+ timer = null;
13
+ inFlight = false;
14
+ intervalSec;
15
+ now;
16
+ lastAttemptAt = null;
17
+ lastCompletedAt = null;
18
+ lastOutcome = null;
19
+ lastError = null;
20
+ lastSummary = null;
21
+ constructor(deps) {
22
+ this.deps = deps;
23
+ this.intervalSec = Math.max(15, Math.min(3600, deps.intervalSec ?? DEFAULT_CLOCK_INTERVAL_SEC));
24
+ this.now = deps.now ?? (() => new Date());
25
+ }
26
+ status() {
27
+ return {
28
+ schemaVersion: 1,
29
+ enabled: this.enabled,
30
+ running: this.inFlight,
31
+ intervalSec: this.intervalSec,
32
+ scope: "console-foreground",
33
+ disclaimer: NIGHT_AUX_TICKER_DISCLAIMER,
34
+ lastAttemptAt: this.lastAttemptAt,
35
+ lastCompletedAt: this.lastCompletedAt,
36
+ lastOutcome: this.lastOutcome,
37
+ lastError: this.lastError,
38
+ lastSummary: this.lastSummary,
39
+ };
40
+ }
41
+ setEnabled(enabled) {
42
+ if (enabled === this.enabled)
43
+ return this.status();
44
+ this.enabled = enabled;
45
+ if (enabled) {
46
+ this.startTimer();
47
+ void this.fire("enable");
48
+ }
49
+ else {
50
+ this.stopTimer();
51
+ }
52
+ return this.status();
53
+ }
54
+ dispose() {
55
+ this.enabled = false;
56
+ this.stopTimer();
57
+ }
58
+ startTimer() {
59
+ this.stopTimer();
60
+ this.timer = setInterval(() => {
61
+ void this.fire("interval");
62
+ }, this.intervalSec * 1000);
63
+ // Do not keep the process alive solely for the aux ticker.
64
+ this.timer.unref?.();
65
+ }
66
+ stopTimer() {
67
+ if (this.timer) {
68
+ clearInterval(this.timer);
69
+ this.timer = null;
70
+ }
71
+ }
72
+ async fire(_reason) {
73
+ if (!this.enabled)
74
+ return;
75
+ if (this.inFlight) {
76
+ this.lastOutcome = "skipped";
77
+ this.lastSummary = "previous tick still running";
78
+ return;
79
+ }
80
+ this.inFlight = true;
81
+ this.lastAttemptAt = this.now().toISOString();
82
+ this.lastError = null;
83
+ try {
84
+ const result = await (this.deps.runTick
85
+ ? this.deps.runTick()
86
+ : this.defaultRunTick());
87
+ this.lastCompletedAt = this.now().toISOString();
88
+ if (result.ok) {
89
+ this.lastOutcome = "ok";
90
+ this.lastSummary = result.summary;
91
+ this.lastError = null;
92
+ }
93
+ else {
94
+ this.lastOutcome = "error";
95
+ this.lastSummary = result.summary;
96
+ this.lastError = result.error ?? result.summary;
97
+ }
98
+ }
99
+ catch (error) {
100
+ this.lastCompletedAt = this.now().toISOString();
101
+ this.lastOutcome = "error";
102
+ this.lastError = error instanceof Error ? error.message : String(error);
103
+ this.lastSummary = null;
104
+ }
105
+ finally {
106
+ this.inFlight = false;
107
+ }
108
+ }
109
+ async defaultRunTick() {
110
+ const bin = resolveSiblingAgentWorkerBin();
111
+ const result = await this.deps.client.runExternal(bin, [
112
+ "scheduler",
113
+ "tick",
114
+ "--repo",
115
+ this.deps.repoRoot,
116
+ "--clock-source",
117
+ "manual",
118
+ "--json",
119
+ ], {
120
+ cwd: this.deps.repoRoot,
121
+ artifactName: "console-night-aux-tick",
122
+ expectJson: true,
123
+ });
124
+ if (!result.ok || result.exitCode !== 0) {
125
+ const message = (result.stderr || result.stdout || "").trim() ||
126
+ `scheduler tick exit ${result.exitCode}`;
127
+ return { ok: false, summary: message, error: message };
128
+ }
129
+ const json = result.json;
130
+ const claimed = Array.isArray(json?.claimed) ? json.claimed.length : 0;
131
+ const dispatched = Array.isArray(json?.dispatched)
132
+ ? json.dispatched.length
133
+ : 0;
134
+ const waiting = Array.isArray(json?.waiting) ? json.waiting.length : 0;
135
+ const skipped = Array.isArray(json?.skipped) ? json.skipped.length : 0;
136
+ return {
137
+ ok: true,
138
+ summary: `claimed=${claimed} dispatched=${dispatched} waiting=${waiting} skipped=${skipped}`,
139
+ };
140
+ }
141
+ }
@@ -134,12 +134,10 @@ async function finalizeFromWorkerResult(operationId, result, deps) {
134
134
  artifactRefs,
135
135
  stdoutPreview: previewStdout,
136
136
  stderrPreview: previewStderr,
137
- result: envelope ?? {
138
- exitCode: result.exitCode,
139
- ok: result.ok,
137
+ result: materializeOperationResult(result, envelope, {
140
138
  stdout: previewStdout,
141
139
  stderr: previewStderr,
142
- },
140
+ }),
143
141
  errorCode: ok ? undefined : (envelope?.error?.code ?? "INTERNAL_ERROR"),
144
142
  errorMessage: ok
145
143
  ? undefined
@@ -166,6 +164,25 @@ function coerceEnvelope(result) {
166
164
  }
167
165
  return undefined;
168
166
  }
167
+ /**
168
+ * Prefer OperatorCommandResultV1, then structured CLI JSON (e.g. admission
169
+ * reviewPacket), else a minimal exit preview. Losing CLI JSON breaks Console
170
+ * wizards that need scheduleId / gateToken after async operations.
171
+ */
172
+ export function materializeOperationResult(result, envelope, previews) {
173
+ if (envelope)
174
+ return envelope;
175
+ const json = result.json;
176
+ if (json && typeof json === "object" && !Array.isArray(json)) {
177
+ return json;
178
+ }
179
+ return {
180
+ exitCode: result.exitCode,
181
+ ok: result.ok,
182
+ stdout: previews.stdout,
183
+ stderr: previews.stderr,
184
+ };
185
+ }
169
186
  /** Fire-and-forget scheduler for accepted operations. */
170
187
  export function scheduleOperation(operationId, deps) {
171
188
  void runOperation(operationId, deps).catch((error) => {
@@ -6,5 +6,17 @@ export function formatOperatorUserError(message) {
6
6
  if (/controller identity changed|package fingerprint changed|entry binary sha256 changed/i.test(raw)) {
7
7
  return "控制器版本已变更(Console 启动后代码被重新编译)。请重启 Console 后再试,一次会话中途不要升级/重建控制器。";
8
8
  }
9
+ if (/Pi session file missing or not openable|cannot reopen chat session .* no persisted Pi session file/i.test(raw)) {
10
+ return "该对话的 Pi 会话文件已丢失,无法恢复上下文。请新建对话继续(历史记录无法重开)。";
11
+ }
12
+ if (/reopened Pi sessionId mismatch/i.test(raw)) {
13
+ return "重开对话失败:Pi 会话身份与记录不一致(常见于会话文件丢失后被误建)。请新建对话继续。";
14
+ }
15
+ if (/base HEAD .+ != frozen baseCommit|exact-base|not a descendant of frozen baseCommit/i.test(raw)) {
16
+ return "主分支已相对夜间冻结的 baseCommit 前进,无法安全 fast-forward harvest。请人工核对后使用 Discard(必要时勾选强制),或改走日间合并流程。";
17
+ }
18
+ if (/force-required|requires --force/i.test(raw)) {
19
+ return "该终态需要强制 Discard。请确认后勾选强制再试。";
20
+ }
9
21
  return raw;
10
22
  }
@@ -62,7 +62,7 @@ const CTA = {
62
62
  requiresReason: true,
63
63
  action: "dagRerun",
64
64
  lane: "dag",
65
- guide: "子图重跑:需选起点且 plan 合格;含 writer 的早期节点常不可用",
65
+ guide: "终端失败默认首选:先 dagRerunPlan,plan 合格再 Human Gate 执行;provider 抖动/只读下游优先,勿无理由新开 task",
66
66
  commandHint: "loop-agent dag rerun --run-id <run-id> --from-node <node-id> --plan",
67
67
  },
68
68
  standaloneTaskRerun: {
@@ -71,8 +71,8 @@ const CTA = {
71
71
  requiresReason: true,
72
72
  action: "standaloneTaskRerun",
73
73
  lane: "dag",
74
- guide: "整单重跑:失败类默认首选;需确认原因后提交",
75
- commandHint: "loop-agent task advance <task-id> --json",
74
+ guide: "整单重跑:仅当 from-node plan 不合格(writer/decision/fingerprint)或契约/源真变时;需确认原因",
75
+ commandHint: "loop-agent dag rerun-task --run-id <run-id> --reason <text> --json",
76
76
  },
77
77
  workerTaskRetry: {
78
78
  id: "workerTaskRetry",
@@ -92,8 +92,8 @@ const MATRIX = {
92
92
  "verification-failed": [
93
93
  "report",
94
94
  "doctor",
95
- "standaloneTaskRerun",
96
95
  "dagRerun",
96
+ "standaloneTaskRerun",
97
97
  "resume",
98
98
  "regenerate",
99
99
  ],
@@ -149,7 +149,7 @@ export function consoleRecoveryCtas(factClass) {
149
149
  }
150
150
  /**
151
151
  * Suggested primary CTA for the current fact class.
152
- * Prefer safe whole-task recovery over node rerun when early writers often block.
152
+ * Prefer node subgraph rerun (cheaper) over whole-task rerun; plan gate still fail-closes unsafe closures.
153
153
  */
154
154
  export function recommendedRecoveryCtaId(factClass) {
155
155
  const allowed = new Set(consoleRecoveryCtas(factClass).map((c) => c.id));
@@ -163,7 +163,7 @@ export function recommendedRecoveryCtaId(factClass) {
163
163
  switch (factClass) {
164
164
  case "terminal-failed":
165
165
  case "verification-failed":
166
- return pick("standaloneTaskRerun", "dagRerun", "doctor", "report");
166
+ return pick("dagRerun", "standaloneTaskRerun", "doctor", "report");
167
167
  case "pause-on-human":
168
168
  return pick("doctor", "report");
169
169
  case "needs-reconcile":
@@ -332,6 +332,59 @@ export async function serveConsoleStatic(req, res, ctx) {
332
332
  res.writeHead(200, headers);
333
333
  res.end(content);
334
334
  }
335
+ async function handleNightAuxTickerGet(_req, res, ctx) {
336
+ const ticker = ctx.operator?.nightAuxTicker;
337
+ if (!ticker) {
338
+ sendJson(res, 503, {
339
+ ok: false,
340
+ error: {
341
+ code: "INTERNAL_ERROR",
342
+ message: "night aux ticker unavailable",
343
+ },
344
+ });
345
+ return;
346
+ }
347
+ sendJson(res, 200, { ok: true, ticker: ticker.status() });
348
+ }
349
+ async function handleNightAuxTickerPost(req, res, ctx) {
350
+ const ticker = ctx.operator?.nightAuxTicker;
351
+ if (!ticker) {
352
+ sendJson(res, 503, {
353
+ ok: false,
354
+ error: {
355
+ code: "INTERNAL_ERROR",
356
+ message: "night aux ticker unavailable",
357
+ },
358
+ });
359
+ return;
360
+ }
361
+ let body;
362
+ try {
363
+ body = (await readJsonBody(req));
364
+ }
365
+ catch (error) {
366
+ sendJson(res, 400, {
367
+ ok: false,
368
+ error: {
369
+ code: "INVALID_INPUT",
370
+ message: error instanceof Error ? error.message : String(error),
371
+ },
372
+ });
373
+ return;
374
+ }
375
+ if (typeof body.enabled !== "boolean") {
376
+ sendJson(res, 400, {
377
+ ok: false,
378
+ error: {
379
+ code: "INVALID_INPUT",
380
+ message: "enabled (boolean) is required",
381
+ },
382
+ });
383
+ return;
384
+ }
385
+ const status = ticker.setEnabled(body.enabled);
386
+ sendJson(res, 200, { ok: true, ticker: status });
387
+ }
335
388
  export async function handleConsoleOperatorRequest(req, res, ctx) {
336
389
  const method = (req.method ?? "GET").toUpperCase();
337
390
  const url = new URL(req.url ?? "/", "http://localhost");
@@ -357,6 +410,10 @@ export async function handleConsoleOperatorRequest(req, res, ctx) {
357
410
  await handleCreateOperation(req, res, ctx);
358
411
  return true;
359
412
  }
413
+ if (method === "GET" && pathname === "/api/operator/v1/night-aux-ticker") {
414
+ await handleNightAuxTickerGet(req, res, ctx);
415
+ return true;
416
+ }
360
417
  const opMatch = pathname.match(/^\/api\/operator\/v1\/operations\/([^/]+)(?:\/(events))?$/);
361
418
  if (opMatch) {
362
419
  const operationId = decodeURIComponent(opMatch[1]);
@@ -383,6 +440,10 @@ export async function handleConsoleOperatorRequest(req, res, ctx) {
383
440
  });
384
441
  return true;
385
442
  }
443
+ if (method === "POST" && pathname === "/api/operator/v1/night-aux-ticker") {
444
+ await handleNightAuxTickerPost(req, res, ctx);
445
+ return true;
446
+ }
386
447
  // Allow other operator POSTs only under known routes; unknown → 404
387
448
  sendJson(res, 404, {
388
449
  ok: false,