arcane-agents 1.2.0 → 1.2.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 (43) hide show
  1. package/README.md +64 -48
  2. package/config.example.yaml +1 -0
  3. package/dist/client/assets/index-CT0NFttM.css +32 -0
  4. package/dist/client/assets/index-DHyA1AST.js +83 -0
  5. package/dist/client/index.html +2 -2
  6. package/dist/server/server/bootstrap/serverContext.js +8 -3
  7. package/dist/server/server/cli.js +120 -2
  8. package/dist/server/server/config/schema.js +5 -1
  9. package/dist/server/server/http/routes/registerApiRoutes.js +10 -0
  10. package/dist/server/server/orchestrator/orchestratorService.js +29 -0
  11. package/dist/server/server/orchestrator/orchestratorService.test.js +59 -0
  12. package/dist/server/server/orchestrator/spawn/resolveSpawnPlan.test.js +1 -0
  13. package/dist/server/server/setup/prerequisites.js +74 -0
  14. package/dist/server/server/setup/prerequisites.test.js +50 -0
  15. package/dist/server/server/status/activityParser.js +1 -1
  16. package/dist/server/server/status/engine/signalContext.js +20 -7
  17. package/dist/server/server/status/engine/stateMachine/constants.js +6 -2
  18. package/dist/server/server/status/engine/stateMachine/decision.js +17 -0
  19. package/dist/server/server/status/engine/stateMachine/decision.test.js +64 -0
  20. package/dist/server/server/status/engine/stateMachine/helpers.js +7 -1
  21. package/dist/server/server/status/engine/stateMachine/helpers.test.js +10 -1
  22. package/dist/server/server/status/engine/stateMachine/idleBlockers.js +13 -0
  23. package/dist/server/server/status/engine/stateMachine/workingEvidence.js +38 -0
  24. package/dist/server/server/status/runtime/activityTextExtractors.js +40 -0
  25. package/dist/server/server/status/runtime/claudeSignals.js +114 -33
  26. package/dist/server/server/status/runtime/claudeSignals.test.js +23 -0
  27. package/dist/server/server/status/runtime/codexSignals.js +132 -0
  28. package/dist/server/server/status/runtime/codexSignals.test.js +47 -0
  29. package/dist/server/server/status/runtime/runtimeProcess.js +86 -0
  30. package/dist/server/server/status/runtime/sessionDetection.js +15 -0
  31. package/dist/server/server/status/runtimeSignals.js +8 -1
  32. package/dist/server/server/status/statusEvaluator.js +4 -2
  33. package/dist/server/server/status/statusMonitor.js +22 -4
  34. package/dist/server/server/status/statusMonitor.test.js +9 -2
  35. package/dist/server/server/status/statusPipeline.js +14 -4
  36. package/dist/server/server/tmux/tmuxAdapter.js +30 -51
  37. package/dist/server/server/tmux/tmuxClient.js +35 -0
  38. package/dist/server/server/tmux/tmuxClient.test.js +58 -0
  39. package/dist/server/server/ws/terminalBridge.js +16 -2
  40. package/dist/server/shared/mapConstants.js +4 -0
  41. package/package.json +4 -3
  42. package/dist/client/assets/index-CWU29xaz.css +0 -32
  43. package/dist/client/assets/index-DNXJVqF0.js +0 -83
@@ -50,15 +50,21 @@ function createContext(overrides = {}) {
50
50
  },
51
51
  runtimeActivityText: undefined,
52
52
  activeClaudeTask: undefined,
53
+ activeRuntimeProcess: undefined,
54
+ hasClaudePromptSignal: false,
53
55
  hasClaudeProgressSignal: false,
54
56
  hasOpenCodePromptSignal: false,
55
57
  hasOpenCodeActiveSignal: false,
58
+ hasCodexPromptSignal: false,
59
+ hasCodexActiveSignal: false,
56
60
  isClaudeSession: false,
57
61
  isOpenCodeSession: false,
62
+ isCodexSession: false,
58
63
  outputQuietForMs: 1_000,
59
64
  commandQuietForMs: 5_000,
60
65
  workerAgeMs: 30_000,
61
66
  interactiveCommands: new Set(),
67
+ runtimeFreshnessWindowMs: undefined,
62
68
  ...overrides
63
69
  };
64
70
  }
@@ -150,4 +156,62 @@ function createContext(overrides = {}) {
150
156
  (0, vitest_1.expect)(decision.status).toBe("idle");
151
157
  (0, vitest_1.expect)(decision.reasons.some((r) => r.code === "opencode-spawn-grace-idle")).toBe(true);
152
158
  });
159
+ (0, vitest_1.it)("returns attention for Codex approval prompts", () => {
160
+ const decision = (0, decision_1.deriveWorkerStatusDecision)(createContext({
161
+ worker: {
162
+ ...createWorker(),
163
+ runtimeId: "codex",
164
+ runtimeLabel: "Codex",
165
+ command: ["codex"]
166
+ },
167
+ currentCommand: "bash",
168
+ commandLower: "bash",
169
+ runtimeActivityText: "Waiting for approval",
170
+ hasCodexPromptSignal: true,
171
+ hasCodexActiveSignal: false,
172
+ isCodexSession: true
173
+ }));
174
+ (0, vitest_1.expect)(decision.status).toBe("attention");
175
+ (0, vitest_1.expect)(decision.activityText).toBe("Waiting for approval");
176
+ (0, vitest_1.expect)(decision.reasons[0]?.code).toBe("codex-approval-prompt");
177
+ });
178
+ (0, vitest_1.it)("returns working when a wrapped agent runtime process is active", () => {
179
+ const decision = (0, decision_1.deriveWorkerStatusDecision)(createContext({
180
+ currentCommand: "bash",
181
+ commandLower: "bash",
182
+ activeRuntimeProcess: {
183
+ pid: 42,
184
+ runtime: "codex",
185
+ command: "codex",
186
+ args: "codex exec"
187
+ },
188
+ isCodexSession: true,
189
+ outputQuietForMs: 45_000
190
+ }));
191
+ (0, vitest_1.expect)(decision.status).toBe("working");
192
+ (0, vitest_1.expect)(decision.reasons.some((reason) => reason.code === "agent-runtime-child-process")).toBe(true);
193
+ });
194
+ (0, vitest_1.it)("returns idle when the Claude prompt is visible under a shell wrapper", () => {
195
+ const decision = (0, decision_1.deriveWorkerStatusDecision)(createContext({
196
+ worker: {
197
+ ...createWorker(),
198
+ runtimeId: "claude",
199
+ runtimeLabel: "Claude",
200
+ command: ["claude"]
201
+ },
202
+ currentCommand: "bash",
203
+ commandLower: "bash",
204
+ isClaudeSession: true,
205
+ hasClaudePromptSignal: true,
206
+ activeRuntimeProcess: {
207
+ pid: 42,
208
+ runtime: "claude",
209
+ command: "claude",
210
+ args: "claude"
211
+ },
212
+ outputQuietForMs: 45_000
213
+ }));
214
+ (0, vitest_1.expect)(decision.status).toBe("idle");
215
+ (0, vitest_1.expect)(decision.reasons[0]?.code).toBe("shell-command-idle");
216
+ });
153
217
  });
@@ -22,7 +22,7 @@ function recentNormalizedLines(output, limit) {
22
22
  .map((line) => line.toLowerCase());
23
23
  }
24
24
  function isAgentRuntime(context) {
25
- return context.isOpenCodeSession || context.isClaudeSession;
25
+ return context.isOpenCodeSession || context.isClaudeSession || context.isCodexSession;
26
26
  }
27
27
  function shouldSuppressShellHistorySignals(context) {
28
28
  if (!isShellCommand(context.commandLower) && !isInteractiveCommand(context)) {
@@ -53,12 +53,18 @@ function looksLikeActiveRuntimeText(activityText) {
53
53
  return normalized.startsWith("thinking");
54
54
  }
55
55
  function statusFreshnessWindowMs(context) {
56
+ if (context.runtimeFreshnessWindowMs !== undefined) {
57
+ return context.runtimeFreshnessWindowMs;
58
+ }
56
59
  if (context.isClaudeSession) {
57
60
  return constants_1.claudeWorkingFreshWindowMs;
58
61
  }
59
62
  if (context.isOpenCodeSession) {
60
63
  return constants_1.openCodeWorkingFreshWindowMs;
61
64
  }
65
+ if (context.isCodexSession) {
66
+ return constants_1.codexWorkingFreshWindowMs;
67
+ }
62
68
  return constants_1.genericWorkingFreshWindowMs;
63
69
  }
64
70
  function isShellCommand(commandLower) {
@@ -50,15 +50,21 @@ function createContext(overrides = {}) {
50
50
  },
51
51
  runtimeActivityText: undefined,
52
52
  activeClaudeTask: undefined,
53
+ activeRuntimeProcess: undefined,
54
+ hasClaudePromptSignal: false,
53
55
  hasClaudeProgressSignal: false,
54
56
  hasOpenCodePromptSignal: false,
55
57
  hasOpenCodeActiveSignal: false,
58
+ hasCodexPromptSignal: false,
59
+ hasCodexActiveSignal: false,
56
60
  isClaudeSession: false,
57
61
  isOpenCodeSession: false,
62
+ isCodexSession: false,
58
63
  outputQuietForMs: 200,
59
64
  commandQuietForMs: 300,
60
65
  workerAgeMs: 10_000,
61
66
  interactiveCommands: new Set(),
67
+ runtimeFreshnessWindowMs: undefined,
62
68
  ...overrides
63
69
  };
64
70
  }
@@ -88,7 +94,10 @@ function createContext(overrides = {}) {
88
94
  (0, vitest_1.it)("returns freshness windows by runtime session", () => {
89
95
  (0, vitest_1.expect)((0, helpers_1.statusFreshnessWindowMs)(createContext({ isClaudeSession: true }))).toBe(10_000);
90
96
  (0, vitest_1.expect)((0, helpers_1.statusFreshnessWindowMs)(createContext({ isOpenCodeSession: true }))).toBe(12_000);
91
- (0, vitest_1.expect)((0, helpers_1.statusFreshnessWindowMs)(createContext())).toBe(12_000);
97
+ (0, vitest_1.expect)((0, helpers_1.statusFreshnessWindowMs)(createContext({ isCodexSession: true }))).toBe(10_000);
98
+ (0, vitest_1.expect)((0, helpers_1.statusFreshnessWindowMs)(createContext())).toBe(20_000);
99
+ (0, vitest_1.expect)((0, helpers_1.statusFreshnessWindowMs)(createContext({ runtimeFreshnessWindowMs: 45_000 }))).toBe(45_000);
100
+ (0, vitest_1.expect)((0, helpers_1.statusFreshnessWindowMs)(createContext({ isClaudeSession: true, runtimeFreshnessWindowMs: 30_000 }))).toBe(30_000);
92
101
  });
93
102
  (0, vitest_1.it)("handles helper utility behavior", () => {
94
103
  const values = [];
@@ -54,6 +54,19 @@ function detectIdleBlocker(context, evidence) {
54
54
  }
55
55
  };
56
56
  }
57
+ if (context.isCodexSession &&
58
+ context.workerAgeMs <= constants_1.codexSpawnGraceMs &&
59
+ !context.hasCodexActiveSignal &&
60
+ !context.activeRuntimeProcess &&
61
+ !evidence.parsedStrongSignal) {
62
+ return {
63
+ reason: {
64
+ code: "codex-spawn-grace-idle",
65
+ message: "During early Codex spawn grace window without active signals.",
66
+ detail: `${Math.round(context.workerAgeMs)}ms since worker creation`
67
+ }
68
+ };
69
+ }
57
70
  const activeWindowMs = (0, helpers_1.statusFreshnessWindowMs)(context);
58
71
  if (context.outputQuietForMs > activeWindowMs && context.transcriptSnapshot?.status !== "working") {
59
72
  return {
@@ -37,6 +37,19 @@ function collectWorkingEvidence(context, hasRecoverableParserError) {
37
37
  activityTextCandidates.push(context.runtimeActivityText ?? "Responding");
38
38
  activityToolCandidates.push("terminal");
39
39
  }
40
+ if (context.hasCodexActiveSignal) {
41
+ strongReasons.push({ code: "codex-active-signal", message: "Codex active execution signal detected." });
42
+ activityTextCandidates.push(context.runtimeActivityText ?? "Responding");
43
+ activityToolCandidates.push("terminal");
44
+ }
45
+ if (context.activeRuntimeProcess && !(context.activeRuntimeProcess.runtime === "claude" && context.hasClaudePromptSignal)) {
46
+ strongReasons.push({
47
+ code: "agent-runtime-child-process",
48
+ message: `${labelRuntime(context.activeRuntimeProcess.runtime)} is still running under the pane shell.`
49
+ });
50
+ activityTextCandidates.push(context.runtimeActivityText ?? `${labelRuntime(context.activeRuntimeProcess.runtime)} running`);
51
+ activityToolCandidates.push("terminal");
52
+ }
40
53
  if ((0, helpers_1.looksLikeActiveRuntimeText)(context.runtimeActivityText)) {
41
54
  strongReasons.push({ code: "runtime-activity-text", message: "Runtime activity text indicates active work." });
42
55
  (0, helpers_1.pushMaybe)(activityTextCandidates, context.runtimeActivityText);
@@ -80,6 +93,21 @@ function collectWorkingEvidence(context, hasRecoverableParserError) {
80
93
  activityToolCandidates.push(context.parsed.activity.tool);
81
94
  (0, helpers_1.pushMaybe)(activityPathCandidates, context.parsed.activity.filePath);
82
95
  }
96
+ if (context.worker.status === "working" &&
97
+ !(0, helpers_1.isShellCommand)(context.commandLower) &&
98
+ !(0, helpers_1.isInteractiveCommand)(context) &&
99
+ context.outputQuietForMs <= (0, helpers_1.statusFreshnessWindowMs)(context) &&
100
+ strongReasons.length === 0 &&
101
+ weakReasons.length === 0) {
102
+ weakReasons.push({
103
+ code: "output-still-fresh",
104
+ message: "Output is still within the freshness window; maintaining working status.",
105
+ detail: `${Math.round(context.outputQuietForMs)}ms quiet (window: ${(0, helpers_1.statusFreshnessWindowMs)(context)}ms)`
106
+ });
107
+ (0, helpers_1.pushMaybe)(activityTextCandidates, context.runtimeActivityText ?? context.worker.activityText);
108
+ activityToolCandidates.push(context.worker.activityTool ?? "terminal");
109
+ (0, helpers_1.pushMaybe)(activityPathCandidates, context.worker.activityPath);
110
+ }
83
111
  return {
84
112
  strongReasons,
85
113
  weakReasons,
@@ -98,3 +126,13 @@ function shouldKeepStickyWorking(context, evidence) {
98
126
  }
99
127
  return (0, helpers_1.hasAnyWorkingEvidence)(evidence);
100
128
  }
129
+ function labelRuntime(runtime) {
130
+ switch (runtime) {
131
+ case "claude":
132
+ return "Claude";
133
+ case "opencode":
134
+ return "OpenCode";
135
+ case "codex":
136
+ return "Codex";
137
+ }
138
+ }
@@ -1,8 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.extractRuntimeActivityText = extractRuntimeActivityText;
4
+ const codexSignals_1 = require("./codexSignals");
4
5
  const openCodeCapturePaneLines = 420;
5
6
  const openCodeThinkingContinuationMaxLines = 3;
7
+ const codexCapturePaneLines = 240;
6
8
  function extractRuntimeActivityText(output, session) {
7
9
  if (session.isClaude) {
8
10
  return extractClaudeRuntimeActivityText(output);
@@ -10,6 +12,9 @@ function extractRuntimeActivityText(output, session) {
10
12
  if (session.isOpenCode) {
11
13
  return extractOpenCodeRuntimeActivityText(output);
12
14
  }
15
+ if (session.isCodex) {
16
+ return extractCodexRuntimeActivityText(output);
17
+ }
13
18
  return undefined;
14
19
  }
15
20
  function extractClaudeRuntimeActivityText(output) {
@@ -163,6 +168,41 @@ function normalizeOpenCodeRuntimeLine(line) {
163
168
  }
164
169
  return withoutFrame;
165
170
  }
171
+ function extractCodexRuntimeActivityText(output) {
172
+ const signals = (0, codexSignals_1.detectCodexSignals)(output);
173
+ const linesNewestFirst = output
174
+ .split("\n")
175
+ .slice(-codexCapturePaneLines)
176
+ .map((line) => (0, codexSignals_1.normalizeCodexRuntimeLine)(line))
177
+ .filter((line) => line.length > 0)
178
+ .reverse();
179
+ for (const line of linesNewestFirst) {
180
+ const statusText = (0, codexSignals_1.extractCodexStatusText)(line);
181
+ if (!statusText) {
182
+ continue;
183
+ }
184
+ const normalizedStatus = statusText.toLowerCase();
185
+ if (normalizedStatus.includes("waiting on approval") || normalizedStatus.includes("approval requested")) {
186
+ return "Waiting for approval";
187
+ }
188
+ if (normalizedStatus.includes("waiting on user input") ||
189
+ normalizedStatus.includes("question requested") ||
190
+ normalizedStatus.includes("user input requested")) {
191
+ return "Waiting for input";
192
+ }
193
+ if (normalizedStatus === "finished" || normalizedStatus.includes("agent turn complete")) {
194
+ return undefined;
195
+ }
196
+ return truncateActivityText(statusText, 72);
197
+ }
198
+ if (signals.prompt) {
199
+ return "Waiting for approval";
200
+ }
201
+ if (signals.active) {
202
+ return "Responding";
203
+ }
204
+ return undefined;
205
+ }
166
206
  function recentLinesNewestFirst(output, limit) {
167
207
  return output
168
208
  .split("\n")
@@ -2,55 +2,136 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.extractClaudeActiveTask = extractClaudeActiveTask;
4
4
  exports.hasClaudeLiveProgressSignal = hasClaudeLiveProgressSignal;
5
+ exports.hasClaudePromptSignal = hasClaudePromptSignal;
6
+ exports.detectClaudeSignals = detectClaudeSignals;
5
7
  exports.isGenericClaudeProgressLabel = isGenericClaudeProgressLabel;
8
+ const claudeSignalWindowLines = 120;
9
+ const claudePromptFreshLineWindow = 8;
10
+ const claudeActiveFreshLineWindow = 12;
11
+ const escapeChar = String.fromCharCode(0x1b);
12
+ const bellChar = String.fromCharCode(0x07);
6
13
  function extractClaudeActiveTask(output) {
7
- const linesNewestFirst = output
8
- .split("\n")
9
- .map((line) => line.trim())
10
- .filter((line) => line.length > 0)
11
- .slice(-120)
12
- .reverse();
13
- for (const line of linesNewestFirst) {
14
- const parentheticalMatch = line.match(/^(?:\*|•|·|✶|✻|✢|✽)\s+(.+?)\s+\((?:[^)]*(?:thinking|thought\s+for)[^)]*)\)\s*$/i);
15
- const plainProgressMatch = line.match(/^(?:\*|•|·|✶|✻|✢|✽)\s+(.+?)\s*$/);
16
- const task = parentheticalMatch?.[1]?.trim() ?? plainProgressMatch?.[1]?.trim();
14
+ const lines = recentClaudeLines(output);
15
+ const latestPromptIndex = findLastMatchingIndex(lines, isClaudePromptLine);
16
+ const lowerBound = latestPromptIndex >= 0 ? latestPromptIndex : 0;
17
+ for (let index = lines.length - 1; index >= lowerBound; index -= 1) {
18
+ const task = extractClaudeTaskText(lines[index] ?? "");
17
19
  if (task) {
18
- if (isGenericClaudeProgressLabel(task)) {
19
- continue;
20
- }
21
20
  return task;
22
21
  }
23
22
  }
24
23
  return undefined;
25
24
  }
26
25
  function hasClaudeLiveProgressSignal(output) {
27
- const lines = output
26
+ return detectClaudeSignals(output).active;
27
+ }
28
+ function hasClaudePromptSignal(output) {
29
+ return detectClaudeSignals(output).prompt;
30
+ }
31
+ function detectClaudeSignals(output) {
32
+ const lines = recentClaudeLines(output);
33
+ const newestIndex = lines.length - 1;
34
+ const latestPromptIndex = findLastMatchingIndex(lines, isClaudePromptLine);
35
+ const latestProgressIndex = findLastMatchingIndex(lines, isClaudeProgressLine);
36
+ return {
37
+ prompt: latestPromptIndex >= 0 &&
38
+ newestIndex >= 0 &&
39
+ newestIndex - latestPromptIndex <= claudePromptFreshLineWindow,
40
+ active: latestProgressIndex >= 0 &&
41
+ newestIndex >= 0 &&
42
+ newestIndex - latestProgressIndex <= claudeActiveFreshLineWindow &&
43
+ (latestPromptIndex < 0 || latestProgressIndex >= latestPromptIndex)
44
+ };
45
+ }
46
+ function isGenericClaudeProgressLabel(text) {
47
+ const normalized = text
48
+ .trim()
49
+ .replace(/[.…]+$/, "")
50
+ .toLowerCase();
51
+ return /^(?:whirring|thinking|saut[eé]ed|churned|baked|accomplishing|conversation compacted)\b/.test(normalized);
52
+ }
53
+ function extractClaudeTaskText(line) {
54
+ const task = extractClaudeBulletText(line);
55
+ if (!task || isGenericClaudeProgressLabel(task)) {
56
+ return undefined;
57
+ }
58
+ return task;
59
+ }
60
+ function isClaudeProgressLine(line) {
61
+ const progressText = extractClaudeBulletText(line);
62
+ if (!progressText) {
63
+ return false;
64
+ }
65
+ if (!isGenericClaudeProgressLabel(progressText)) {
66
+ return true;
67
+ }
68
+ return /\bfor\s+\d+[ms]\b/i.test(progressText) || /\((?:[^)]*(?:thinking|thought\s+for)[^)]*)\)/i.test(line);
69
+ }
70
+ function extractClaudeBulletText(line) {
71
+ const parentheticalMatch = line.match(/^(?:\*|•|·|✶|✻|✢|✽)\s+(.+?)\s+\((?:[^)]*(?:thinking|thought\s+for)[^)]*)\)\s*$/i);
72
+ const plainProgressMatch = line.match(/^(?:\*|•|·|✶|✻|✢|✽)\s+(.+?)\s*$/);
73
+ return parentheticalMatch?.[1]?.trim() ?? plainProgressMatch?.[1]?.trim();
74
+ }
75
+ function isClaudePromptLine(line) {
76
+ return /^\u276f$/u.test(line) || /^--\s*insert\s*--.*\bbypass permissions\b/i.test(line) || /\bbypass permissions\b/i.test(line);
77
+ }
78
+ function recentClaudeLines(output) {
79
+ return output
28
80
  .split("\n")
29
- .map((line) => line.trim())
81
+ .map((line) => normalizeClaudeRuntimeLine(line))
30
82
  .filter((line) => line.length > 0)
31
- .slice(-60);
32
- for (const line of lines) {
33
- const progressMatch = line.match(/^(?:\*|•|·|✶|✻|✢|✽)\s+(.+?)\s*$/);
34
- if (!progressMatch?.[1]) {
83
+ .slice(-claudeSignalWindowLines);
84
+ }
85
+ function normalizeClaudeRuntimeLine(line) {
86
+ return stripTerminalControlSequences(line)
87
+ .replace(/\s+/g, " ")
88
+ .trim();
89
+ }
90
+ function stripTerminalControlSequences(line) {
91
+ let normalized = "";
92
+ for (let index = 0; index < line.length; index += 1) {
93
+ const current = line[index] ?? "";
94
+ const next = line[index + 1] ?? "";
95
+ if (current === escapeChar && next === "]") {
96
+ index += 2;
97
+ while (index < line.length) {
98
+ const cursor = line[index] ?? "";
99
+ const following = line[index + 1] ?? "";
100
+ if (cursor === bellChar) {
101
+ break;
102
+ }
103
+ if (cursor === escapeChar && following === "\\") {
104
+ index += 1;
105
+ break;
106
+ }
107
+ index += 1;
108
+ }
35
109
  continue;
36
110
  }
37
- const progressText = progressMatch[1].trim();
38
- if (!progressText) {
111
+ if (current === escapeChar && next === "[") {
112
+ index += 2;
113
+ while (index < line.length) {
114
+ const code = line.charCodeAt(index);
115
+ if (code >= 0x40 && code <= 0x7e) {
116
+ break;
117
+ }
118
+ index += 1;
119
+ }
39
120
  continue;
40
121
  }
41
- if (!isGenericClaudeProgressLabel(progressText)) {
42
- return true;
43
- }
44
- if (/\bfor\s+\d+[ms]\b/i.test(progressText) || /\((?:[^)]*(?:thinking|thought\s+for)[^)]*)\)/i.test(line)) {
45
- return true;
122
+ const code = current.charCodeAt(0);
123
+ if ((code >= 0x00 && code <= 0x08) || (code >= 0x0b && code <= 0x1a) || (code >= 0x1c && code <= 0x1f) || code === 0x7f) {
124
+ continue;
46
125
  }
126
+ normalized += current;
47
127
  }
48
- return false;
128
+ return normalized;
49
129
  }
50
- function isGenericClaudeProgressLabel(text) {
51
- const normalized = text
52
- .trim()
53
- .replace(/[.…]+$/, "")
54
- .toLowerCase();
55
- return /^(?:whirring|thinking|saut[eé]ed|churned|baked|accomplishing|conversation compacted)\b/.test(normalized);
130
+ function findLastMatchingIndex(lines, predicate) {
131
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
132
+ if (predicate(lines[index] ?? "")) {
133
+ return index;
134
+ }
135
+ }
136
+ return -1;
56
137
  }
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vitest_1 = require("vitest");
4
+ const claudeSignals_1 = require("./claudeSignals");
5
+ (0, vitest_1.describe)("claudeSignals", () => {
6
+ (0, vitest_1.it)("stops treating progress lines as active once the Claude prompt returns", () => {
7
+ const output = [
8
+ "• Reviewing the final patch",
9
+ "✻ Churned for 1m 42s",
10
+ "",
11
+ "❯",
12
+ " -- INSERT -- ⏵⏵ bypass permissions on (shift+tab to cycle)"
13
+ ].join("\n");
14
+ (0, vitest_1.expect)((0, claudeSignals_1.hasClaudePromptSignal)(output)).toBe(true);
15
+ (0, vitest_1.expect)((0, claudeSignals_1.hasClaudeLiveProgressSignal)(output)).toBe(false);
16
+ (0, vitest_1.expect)((0, claudeSignals_1.extractClaudeActiveTask)(output)).toBeUndefined();
17
+ });
18
+ (0, vitest_1.it)("keeps reporting live progress when Claude is still actively working", () => {
19
+ const output = ["✻ Churned for 12s", "", "Thinking through the next change"].join("\n");
20
+ (0, vitest_1.expect)((0, claudeSignals_1.hasClaudePromptSignal)(output)).toBe(false);
21
+ (0, vitest_1.expect)((0, claudeSignals_1.hasClaudeLiveProgressSignal)(output)).toBe(true);
22
+ });
23
+ });
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.detectCodexSignals = detectCodexSignals;
4
+ exports.hasCodexPromptSignal = hasCodexPromptSignal;
5
+ exports.hasCodexActiveSignal = hasCodexActiveSignal;
6
+ exports.extractCodexStatusText = extractCodexStatusText;
7
+ exports.normalizeCodexRuntimeLine = normalizeCodexRuntimeLine;
8
+ const codexSignalWindowLines = 240;
9
+ const codexPromptFreshLineWindow = 24;
10
+ const codexActiveFreshLineWindow = 12;
11
+ const escapeChar = String.fromCharCode(0x1b);
12
+ const bellChar = String.fromCharCode(0x07);
13
+ const codexPromptMatchers = [
14
+ /needs your approval\./i,
15
+ /^would you like to run the following command\?/i,
16
+ /^would you like to make the following edits\?/i,
17
+ /^do you want to approve network access to /i,
18
+ /^permission rule:/i,
19
+ /^yes, just this once$/i,
20
+ /^yes, and don't ask again/i,
21
+ /^yes, and allow these permissions for this session$/i,
22
+ /^yes, and allow this host /i,
23
+ /^yes, provide the requested info$/i,
24
+ /^no, but continue without it$/i,
25
+ /^no, continue without running it$/i,
26
+ /^no, and tell codex what to do differently$/i,
27
+ /^cancel this request$/i
28
+ ];
29
+ const codexPromptStatusMatchers = [
30
+ /\bwaiting on approval\b/i,
31
+ /\bwaiting on user input\b/i,
32
+ /\bapproval[-\s]requested\b/i,
33
+ /\buser[-\s]input[-\s]requested\b/i,
34
+ /\bquestion requested\b/i
35
+ ];
36
+ const codexActiveMatchers = [/\besc to interrupt\b/i];
37
+ function detectCodexSignals(output) {
38
+ const lines = output
39
+ .split("\n")
40
+ .slice(-codexSignalWindowLines)
41
+ .map((line) => normalizeCodexRuntimeLine(line))
42
+ .filter((line) => line.length > 0);
43
+ const newestIndex = lines.length - 1;
44
+ const latestPromptIndex = findLastMatchingIndex(lines, (line) => isCodexPromptLine(line) || hasCodexPromptStatus(line));
45
+ const latestActiveIndex = findLastMatchingIndex(lines, (line) => codexActiveMatchers.some((matcher) => matcher.test(line)));
46
+ return {
47
+ prompt: latestPromptIndex >= 0 &&
48
+ newestIndex >= 0 &&
49
+ newestIndex - latestPromptIndex <= codexPromptFreshLineWindow,
50
+ active: latestActiveIndex >= 0 &&
51
+ newestIndex >= 0 &&
52
+ newestIndex - latestActiveIndex <= codexActiveFreshLineWindow
53
+ };
54
+ }
55
+ function hasCodexPromptSignal(output) {
56
+ return detectCodexSignals(output).prompt;
57
+ }
58
+ function hasCodexActiveSignal(output) {
59
+ return detectCodexSignals(output).active;
60
+ }
61
+ function extractCodexStatusText(line) {
62
+ const match = normalizeCodexRuntimeLine(line).match(/^status:\s+(.+)$/i);
63
+ return match?.[1]?.trim() || undefined;
64
+ }
65
+ function normalizeCodexRuntimeLine(line) {
66
+ const normalized = stripTerminalControlSequences(line)
67
+ .replace(/\s+/g, " ")
68
+ .trim();
69
+ if (!normalized) {
70
+ return "";
71
+ }
72
+ const withoutFrame = normalized.replace(/^[│┃╹▀▣⬝■•·]+/, "").trim();
73
+ if (!withoutFrame || /^╹?▀+$/.test(withoutFrame)) {
74
+ return "";
75
+ }
76
+ return withoutFrame;
77
+ }
78
+ function stripTerminalControlSequences(line) {
79
+ let normalized = "";
80
+ for (let index = 0; index < line.length; index += 1) {
81
+ const current = line[index] ?? "";
82
+ const next = line[index + 1] ?? "";
83
+ if (current === escapeChar && next === "]") {
84
+ index += 2;
85
+ while (index < line.length) {
86
+ const cursor = line[index] ?? "";
87
+ const following = line[index + 1] ?? "";
88
+ if (cursor === bellChar) {
89
+ break;
90
+ }
91
+ if (cursor === escapeChar && following === "\\") {
92
+ index += 1;
93
+ break;
94
+ }
95
+ index += 1;
96
+ }
97
+ continue;
98
+ }
99
+ if (current === escapeChar && next === "[") {
100
+ index += 2;
101
+ while (index < line.length) {
102
+ const code = line.charCodeAt(index);
103
+ if (code >= 0x40 && code <= 0x7e) {
104
+ break;
105
+ }
106
+ index += 1;
107
+ }
108
+ continue;
109
+ }
110
+ const code = current.charCodeAt(0);
111
+ if ((code >= 0x00 && code <= 0x08) || (code >= 0x0b && code <= 0x1a) || (code >= 0x1c && code <= 0x1f) || code === 0x7f) {
112
+ continue;
113
+ }
114
+ normalized += current;
115
+ }
116
+ return normalized;
117
+ }
118
+ function hasCodexPromptStatus(line) {
119
+ const statusText = extractCodexStatusText(line);
120
+ return statusText ? codexPromptStatusMatchers.some((matcher) => matcher.test(statusText)) : false;
121
+ }
122
+ function isCodexPromptLine(line) {
123
+ return codexPromptMatchers.some((matcher) => matcher.test(line));
124
+ }
125
+ function findLastMatchingIndex(lines, predicate) {
126
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
127
+ if (predicate(lines[index] ?? "")) {
128
+ return index;
129
+ }
130
+ }
131
+ return -1;
132
+ }
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vitest_1 = require("vitest");
4
+ const activityTextExtractors_1 = require("./activityTextExtractors");
5
+ const codexSignals_1 = require("./codexSignals");
6
+ (0, vitest_1.describe)("detectCodexSignals", () => {
7
+ (0, vitest_1.it)("detects approval prompts from Codex terminal output", () => {
8
+ const output = `
9
+ Would you like to run the following command?
10
+ Permission rule: Yes, just this once
11
+ Yes, and don't ask again for this command in this session
12
+ No, continue without running it
13
+ `;
14
+ (0, vitest_1.expect)((0, codexSignals_1.detectCodexSignals)(output)).toEqual({
15
+ prompt: true,
16
+ active: false
17
+ });
18
+ });
19
+ (0, vitest_1.it)("detects active Codex turns from interrupt hints", () => {
20
+ const output = `
21
+ Searching repository
22
+ esc to interrupt
23
+ `;
24
+ (0, vitest_1.expect)((0, codexSignals_1.detectCodexSignals)(output)).toEqual({
25
+ prompt: false,
26
+ active: true
27
+ });
28
+ });
29
+ });
30
+ (0, vitest_1.describe)("extractRuntimeActivityText", () => {
31
+ (0, vitest_1.it)("maps Codex approval status lines to waiting text", () => {
32
+ const output = "Status: Waiting on approval";
33
+ (0, vitest_1.expect)((0, activityTextExtractors_1.extractRuntimeActivityText)(output, {
34
+ isClaude: false,
35
+ isOpenCode: false,
36
+ isCodex: true
37
+ })).toBe("Waiting for approval");
38
+ });
39
+ (0, vitest_1.it)("maps Codex interrupt hints to responding text", () => {
40
+ const output = "Scanning files\nesc to interrupt";
41
+ (0, vitest_1.expect)((0, activityTextExtractors_1.extractRuntimeActivityText)(output, {
42
+ isClaude: false,
43
+ isOpenCode: false,
44
+ isCodex: true
45
+ })).toBe("Responding");
46
+ });
47
+ });