@bill10/agent-007 0.6.2

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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -0
  3. package/VERSION +1 -0
  4. package/bin/adduser.js +69 -0
  5. package/bin/agent-007.js +88 -0
  6. package/lib/cron.js +189 -0
  7. package/lib/helpers.js +541 -0
  8. package/lib/jobs.js +965 -0
  9. package/package.json +63 -0
  10. package/public/app.js +650 -0
  11. package/public/assets/characters/LICENSE +21 -0
  12. package/public/assets/characters/char_0.png +0 -0
  13. package/public/assets/characters/char_1.png +0 -0
  14. package/public/assets/characters/char_2.png +0 -0
  15. package/public/assets/characters/char_3.png +0 -0
  16. package/public/assets/characters/char_4.png +0 -0
  17. package/public/assets/characters/char_5.png +0 -0
  18. package/public/assets/furniture/bookshelf.png +0 -0
  19. package/public/assets/furniture/cactus.png +0 -0
  20. package/public/assets/furniture/chair_back.png +0 -0
  21. package/public/assets/furniture/chair_front.png +0 -0
  22. package/public/assets/furniture/chair_side.png +0 -0
  23. package/public/assets/furniture/coffee.png +0 -0
  24. package/public/assets/furniture/coffee_table.png +0 -0
  25. package/public/assets/furniture/desk.png +0 -0
  26. package/public/assets/furniture/desk2.png +0 -0
  27. package/public/assets/furniture/plant_2.png +0 -0
  28. package/public/assets/furniture/sofa_front.png +0 -0
  29. package/public/assets/furniture/sofa_side.png +0 -0
  30. package/public/assets/furniture/table_front.png +0 -0
  31. package/public/index.html +245 -0
  32. package/public/modules/auth.js +83 -0
  33. package/public/modules/explorer.js +760 -0
  34. package/public/modules/jobs.js +971 -0
  35. package/public/modules/office.js +2154 -0
  36. package/public/modules/paths.js +20 -0
  37. package/public/modules/shortcuts.js +54 -0
  38. package/public/modules/state.js +75 -0
  39. package/public/modules/terminal.js +651 -0
  40. package/public/modules/voice.js +393 -0
  41. package/public/modules/ws.js +56 -0
  42. package/public/style.css +1843 -0
  43. package/server/agent-mcp-bridge.js +45 -0
  44. package/server/agent-mcp.js +184 -0
  45. package/server/agent-transcripts.js +195 -0
  46. package/server/approvals.js +155 -0
  47. package/server/auth.js +162 -0
  48. package/server/billion.js +176 -0
  49. package/server/claude-trust.js +66 -0
  50. package/server/command-path.js +102 -0
  51. package/server/config.js +184 -0
  52. package/server/direct-run.js +33 -0
  53. package/server/git.js +630 -0
  54. package/server/http.js +276 -0
  55. package/server/jobs.js +2044 -0
  56. package/server/mcp.js +596 -0
  57. package/server/messages.js +319 -0
  58. package/server/permission-hook.js +47 -0
  59. package/server/pty.js +360 -0
  60. package/server/state.js +104 -0
  61. package/server/ws.js +583 -0
  62. package/server.js +306 -0
  63. package/templates/billion/COMPANY.md +14 -0
  64. package/templates/billion/STATE.md +17 -0
  65. package/templates/billion/charter.md +232 -0
  66. package/templates/billion/owner.md +11 -0
package/lib/helpers.js ADDED
@@ -0,0 +1,541 @@
1
+ // Pure helpers extracted from server.js for testability
2
+ import stripAnsi from 'strip-ansi';
3
+ import { basename, resolve } from 'path';
4
+ import { createHash } from 'crypto';
5
+ import { homedir } from 'os';
6
+
7
+ // --- Constants ---
8
+
9
+ export const CODENAMES = [
10
+ 'Shadow', 'Phantom', 'Viper', 'Cipher', 'Raven', 'Onyx', 'Echo',
11
+ 'Spectre', 'Falcon', 'Ghost', 'Dagger', 'Mirage', 'Cobra', 'Apex', 'Ember'
12
+ ];
13
+
14
+ export const COCKTAILS = [
15
+ 'vesper', 'martini', 'gimlet', 'negroni', 'sidecar', 'daiquiri',
16
+ 'manhattan', 'mojito', 'paloma', 'sazerac', 'aviation', 'bellini',
17
+ 'spritz', 'collins', 'julep', 'highball', 'rickey', 'fizz'
18
+ ];
19
+
20
+ export const AGENT_COLORS = [
21
+ '#4a9eff', '#ff6b6b', '#ffd43b', '#51cf66', '#cc5de8',
22
+ '#ff922b', '#20c997', '#f06595', '#5c7cfa', '#ffe066'
23
+ ];
24
+
25
+ export const PROMPT_PATTERNS = [
26
+ /^❯\s*$/,
27
+ /^>\s*$/,
28
+ /\$\s*$/,
29
+ /^claude[->❯]\s*$/i,
30
+ /^\s*\?\s*$/,
31
+ ];
32
+
33
+ export const MESSAGE_PATTERNS = [
34
+ // Claude Code positions each word with cursor moves, so after stripping the
35
+ // words run together ("No,andtellClaudewhattododifferently"): \s* between
36
+ // words wherever a phrase can be Claude's.
37
+ /Do\s*you\s*want\s*to\s*(proceed|allow|make|run|create|fetch)/i,
38
+ /\(y\/n\)/i,
39
+ /\[Y\/n\]/i,
40
+ // Bounded middles, not `.+`: these run over the last synchronized frame
41
+ // too (up to 2000 chars of agent-controlled text), and an unbounded gap
42
+ // between two literals backtracks quadratically. No dialog puts 200 chars
43
+ // between "Allow" and "to", or between "?" and "(Y/n)".
44
+ /Allow [^\n]{1,200} to (read|edit|write|execute)/i,
45
+ /Press Enter to continue/i,
46
+ // The dialogs each CLI opens, by their own wording, and the answers they
47
+ // offer. Word-bounded and phrase-shaped on purpose: this used to be a bare
48
+ // /approve|deny|allow|reject/, which also matched ordinary prose — Codex's
49
+ // permission picker says "choose what Codex is allowed to do" and "Ask for
50
+ // approval", and every line of an agent's own summary is one "allowed"
51
+ // away from a false "needs you" bubble. (Wording verified against
52
+ // codex-cli 0.153.4 and Claude Code 2.1.273; the Claude dialogs are on
53
+ // file in test/fixtures/claude-permission-dialogs.js.)
54
+ /\bWould you like to (run|make|grant|send)\b/i, // Codex: command, edits, permissions, terminal input
55
+ /\bApprove app tool call\?/i,
56
+ /\bApply Changes\?/i,
57
+ /\bEnable full access\?/i,
58
+ /\bUpdate Model Permissions\b/, // the /permissions picker's title — Title Case, unlike prose about it
59
+ // Codex keeps its spaces, so its answers are word-bounded on both sides.
60
+ /\bYes, (proceed|just this once|continue anyway|grant)\b/i,
61
+ /\bNo, (continue without (running|permissions)|and block this host)\b/i,
62
+ // The two answers both CLIs offer, space-tolerant for Claude and with no
63
+ // trailing \b: with the spaces gone the phrase runs straight into the next
64
+ // word ("...whattododifferently").
65
+ /\bYes,\s*and\s*(don['’]t|do\s*not)\s*ask\s*again/i,
66
+ /\bNo,\s*and\s*tell\s*(Codex|Claude)\s*what\s*to\s*do/i,
67
+ // Claude Code 2.1's permission dialog: its answers, and its footer, since
68
+ // four answers push the question itself out of the five-line window.
69
+ /\bYes,\s*and\s*(always\s*allow|switch\s*to)/i,
70
+ /Esc\s*to\s*cancel\s*·\s*Tab\s*to\s*amend/i,
71
+ /\b(Allow for this session|Always allow|Allow and don['’]t ask)\b/i,
72
+ // Gemini CLI and aider, the other TUIs a tab may run, by their own answers.
73
+ /\bAllow execution\?/i,
74
+ /\bYes, allow (once|always)\b/i,
75
+ /\(Y\)es\/\(N\)o/i,
76
+ // Claude Code's TUI dialogs. Two things make these need their own patterns:
77
+ //
78
+ // 1. The TUI positions each word with cursor moves rather than spaces, so
79
+ // after stripAnsiComplete the text arrives run together —
80
+ // "Yes,Itrustthisfolder". Hence \s* between every word, not a literal
81
+ // space. (Verified against real captured PTY output.)
82
+ // 2. Without them a dialog reads as plain WAITING, which for a TUI agent is
83
+ // indistinguishable from "idle at the prompt". The workspace-trust dialog
84
+ // in particular greets EVERY agent spawned into a fresh worktree, so a
85
+ // job-board agent would otherwise sit there looking like it was working.
86
+ /Yes,?\s*I\s*trust\s*this\s*folder/i,
87
+ // Codex's own trust dialog, as 0.156 words it ("› 1. Trust and continue
88
+ // 2. Quit"). Unmatched, it read as WAITING, and an agent message typed into
89
+ // it answered it: the Enter picked option 1 and saved the folder as trusted.
90
+ // (0.155 words it "› 1. Yes, continue 2. No, quit", which "Press Enter to
91
+ // continue" above already catches.)
92
+ /\bTrust\s*and\s*continue\b/i,
93
+ // Codex's hook-trust prompt at startup, when ~/.codex/hooks.json has new or
94
+ // changed hooks ("Hooks need review … Press enter to confirm or esc to go
95
+ // back"), and the hooks browser its option 1 opens ("Press t to trust all").
96
+ // Both read as WAITING, and an agent message's Enter picked "Review hooks".
97
+ /\bHooks\s*need\s*review\b/i,
98
+ /Press\s*enter\s*to\s*confirm\s*or\s*esc/i,
99
+ /Press\s*t\s*to\s*trust\s*all/i,
100
+ // "select" as well as "confirm": the multiple-choice prompt's footer reads
101
+ // "Enter to select ... Esc to cancel", and without it an agent blocking on a
102
+ // question reads as plain WAITING -- indistinguishable from resting at the
103
+ // prompt, which is what now sends it wandering off mid-question.
104
+ //
105
+ // The middle is a bounded [^\n]{0,120} rather than the old `\s*.?\s*`: that
106
+ // allowed exactly ONE character between the verb and "Esc", so the real
107
+ // footer ("Enter to select · up/down to navigate · n to add notes · Esc to
108
+ // cancel") never matched, and its three mutually ambiguous quantifiers
109
+ // backtracked quadratically on a line an agent controls (32k spaces = ~800ms
110
+ // per scan, run against every PTY chunk).
111
+ /Enter\s*to\s*(?:confirm|select)\b[^\n]{0,120}Esc\s*to\s*cancel/i,
112
+ ];
113
+
114
+ export const STATE_TIMEOUT_MS = 3000;
115
+
116
+ // --- Factory: Codename Pool ---
117
+
118
+ export function createCodenamePool(names = CODENAMES) {
119
+ const used = new Set();
120
+ // Names no agent may ever take (Billion's): held for good, whatever else
121
+ // is recycled — an old orphan that happens to carry one included.
122
+ const reserved = new Set();
123
+ return {
124
+ reserve(name) {
125
+ reserved.add(name);
126
+ used.add(name);
127
+ },
128
+ pick() {
129
+ const available = names.filter(n => !used.has(n));
130
+ if (available.length > 0) {
131
+ const name = available[Math.floor(Math.random() * available.length)];
132
+ used.add(name);
133
+ return name;
134
+ }
135
+ for (const base of names) {
136
+ for (let i = 2; i <= 99; i++) {
137
+ const name = `${base}-${i}`;
138
+ if (!used.has(name)) {
139
+ used.add(name);
140
+ return name;
141
+ }
142
+ }
143
+ }
144
+ return `agent-${Date.now()}`;
145
+ },
146
+ recycle(name) {
147
+ if (reserved.has(name)) return;
148
+ used.delete(name);
149
+ },
150
+ has(name) {
151
+ return used.has(name);
152
+ },
153
+ addUsed(name) {
154
+ used.add(name);
155
+ },
156
+ get usedCount() {
157
+ return used.size;
158
+ },
159
+ };
160
+ }
161
+
162
+ // --- Factory: Cocktail Pool ---
163
+
164
+ // Names to TRY, in order. Deliberately not a ledger of what is in use.
165
+ //
166
+ // Git already knows which branches exist — `worktree add -b` answers definitively
167
+ // and atomically, including for branches made outside this app, on another
168
+ // machine, or a second ago. So the caller walks these candidates and stops at the
169
+ // first one git accepts. Nothing here has to stay in sync with anything.
170
+ //
171
+ // `reject` is a speed hint, never a source of truth: a name git turned down goes
172
+ // to the back of the queue so the next spawn tries it last, not never. A branch
173
+ // can be deleted at any time, and a stale hint must not cost us a usable name.
174
+ export function createCocktailPool(names = COCKTAILS) {
175
+ const rejectedByRepo = new Map();
176
+
177
+ // Round 1 is the bare cocktail; later rounds prefix it — 2nd-vesper, 3rd-vesper.
178
+ // These become real branch names, so 21st beats 21th.
179
+ const ordinal = (round) => {
180
+ const suffix = round % 100 >= 11 && round % 100 <= 13
181
+ ? 'th'
182
+ : ({ 1: 'st', 2: 'nd', 3: 'rd' }[round % 10] || 'th');
183
+ return `${round}${suffix}`;
184
+ };
185
+
186
+ const shuffled = (list) => {
187
+ const out = [...list];
188
+ for (let i = out.length - 1; i > 0; i--) {
189
+ const j = Math.floor(Math.random() * (i + 1));
190
+ [out[i], out[j]] = [out[j], out[i]];
191
+ }
192
+ return out;
193
+ };
194
+
195
+ return {
196
+ // Random order within a round so two concurrent spawns rarely open with the
197
+ // same name; if they do, the loser just takes the next candidate.
198
+ *candidates(repoPath, maxRounds = 99) {
199
+ // Bind the live Set, creating it if absent. `?? new Set()` would hand back a
200
+ // detached copy on a repo's first spawn, so rejections made mid-walk would
201
+ // land in a different object than the one being read.
202
+ if (!rejectedByRepo.has(repoPath)) rejectedByRepo.set(repoPath, new Set());
203
+ const rejected = rejectedByRepo.get(repoPath);
204
+ for (let round = 1; round <= maxRounds; round++) {
205
+ const prefix = round === 1 ? '' : `${ordinal(round)}-`;
206
+ const pool = shuffled(names).map(n => `${prefix}${n}`);
207
+ // Partition BEFORE yielding either half. The caller rejects each name as
208
+ // it fails, so a lazily-evaluated second filter would re-collect names the
209
+ // first half already yielded and hand them back inside the same round —
210
+ // burning the attempt budget on names we just proved were taken.
211
+ const fresh = pool.filter(n => !rejected.has(n));
212
+ const stale = pool.filter(n => rejected.has(n));
213
+ yield* fresh; // names with no strike against them
214
+ yield* stale; // then the hints, in case a branch was deleted
215
+ }
216
+ },
217
+ reject(repoPath, name) {
218
+ if (!rejectedByRepo.has(repoPath)) rejectedByRepo.set(repoPath, new Set());
219
+ rejectedByRepo.get(repoPath).add(name);
220
+ },
221
+ };
222
+ }
223
+
224
+ // --- Factory: Color Cycler ---
225
+
226
+ export function createColorCycler(colors = AGENT_COLORS) {
227
+ let index = 0;
228
+ return {
229
+ next() {
230
+ const color = colors[index % colors.length];
231
+ index++;
232
+ return color;
233
+ },
234
+ };
235
+ }
236
+
237
+ // --- Pure Functions ---
238
+
239
+ export function stripAnsiComplete(str) {
240
+ let result = stripAnsi(str);
241
+ result = result.replace(/\x1b\].*?(?:\x07|\x1b\\)/g, '');
242
+ result = result.replace(/\x1bP.*?\x1b\\/g, '');
243
+ result = result.replace(/\x1b[=>()]/g, '');
244
+ return result;
245
+ }
246
+
247
+ // Output that says something happened, as opposed to a spinner tick, a rule
248
+ // of box-drawing, or the remnant of a half-stripped escape (the ' q' left by
249
+ // ESC[0 SP q, the cursor-shape sequence). One test for the freshness clock
250
+ // and for whether a synchronized frame is a repaint worth keeping.
251
+ const TRIVIAL_RE = /^[\s.·•⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⣾⣽⣻⢿⡿⣟⣯⣷─━▏▎▍▌▋▊▉█░▒▓⬡◐◑◒◓|\\\/\-*>]+$/;
252
+ const ESCAPE_REMNANT_RE = /^[\d;]*[a-zA-Z]$/;
253
+ export function isRealOutput(text) {
254
+ return text.length > 3 && !TRIVIAL_RE.test(text) && !ESCAPE_REMNANT_RE.test(text);
255
+ }
256
+
257
+ // Synchronized output (DEC mode 2026): a TUI brackets each repaint in
258
+ // ?2026h ... ?2026l so the terminal shows it whole. Codex draws its bottom
259
+ // pane — composer, status line, and every dialog and picker — that way, so
260
+ // the last complete frame is the pane as it stands: the picker while it is
261
+ // open, the bare prompt once it is answered. detectState reads it in place of
262
+ // the five-line window, which for Codex holds whatever last got a newline and
263
+ // so keeps an answered dialog's text long after the screen let it go.
264
+ //
265
+ // A pty read is not a frame, nor even a whole marker: an open frame's body is
266
+ // carried between reads, and so are the last few bytes before a frame, so a
267
+ // marker cut by a read boundary is still found — a lost end marker would
268
+ // otherwise glue the next repaint onto a stale one, head first, and the
269
+ // answered dialog would be back. A begin marker inside an open frame restarts
270
+ // it: that is a new repaint. A frame whose text is nothing but cursor-shape
271
+ // remnants and whitespace — Codex blinks those while idle — is not a repaint
272
+ // of the pane and leaves the last real frame in place. The raw bound keeps
273
+ // the head; the text kept for matching is the head AND the tail, since a
274
+ // dialog states its question first and lists its answers last, and the agent
275
+ // controls how long whatever sits between them runs.
276
+ //
277
+ // Not every frame is the whole pane. Codex repaints just the option rows
278
+ // when the user arrows through a picker, re-inserts history boxes frame by
279
+ // frame after a resize, and prints each history line ("• Permissions
280
+ // updated …") as a frame of its own. A frame that carries the composer or
281
+ // the status row is the pane and replaces what was held; any other frame is
282
+ // a partial repaint and is merged onto it. The caller says which is which
283
+ // (`pane`), since the status row names the working directory. What that
284
+ // rule still cannot know is the screen itself — a terminal screen model is
285
+ // the full answer; see TODOS.md.
286
+ //
287
+ // A frame left open for more than a second is abandoned: no repaint takes
288
+ // that long, and a writer that died mid-frame must not swallow the rest of
289
+ // the session into a body that never closes.
290
+ const SYNC_BEGIN = '\x1b[?2026h';
291
+ const SYNC_END = '\x1b[?2026l';
292
+ const MARKER_LEN = SYNC_BEGIN.length;
293
+ export const FRAME_RAW_MAX = 64 * 1024;
294
+ export const FRAME_TEXT_MAX = 2000;
295
+ // Returns what this read contributed OUTSIDE any frame — the line stream the
296
+ // handler should reassemble — and how many bytes of the previous read turned
297
+ // out to be the head of a marker straddling the two, so the handler can
298
+ // take them back out of its carry. A frame's bytes are a repaint, never line
299
+ // text: left in the line stream, a cancelled picker's text would still be
300
+ // "the last line" for as long as the next 2000 bytes took to arrive.
301
+ export const FRAME_OPEN_MAX_MS = 1000;
302
+ export function trackSyncFrames(session, data, now = Date.now(), { pane = () => true } = {}) {
303
+ if (typeof data !== 'string' || data.length === 0) return { outside: '', straddle: 0 };
304
+ if (typeof session.frameOpen === 'string' && now - (session.frameOpenedAt || now) > FRAME_OPEN_MAX_MS) {
305
+ session.frameOpen = null; // abandoned; what it held is neither pane nor line text
306
+ session.frameTail = '';
307
+ }
308
+ let open = typeof session.frameOpen === 'string';
309
+ const prefix = open ? session.frameOpen : (session.frameTail || '');
310
+ let buf = prefix + data;
311
+ // Where buf[0] sits in `data` (negative while the prefix is still in front).
312
+ let pos = -prefix.length;
313
+ let at = open ? Math.max(0, prefix.length - (MARKER_LEN - 1)) : 0;
314
+ let outside = '';
315
+ let straddle = 0;
316
+ for (;;) {
317
+ const begin = buf.indexOf(SYNC_BEGIN, at);
318
+ const end = open ? buf.indexOf(SYNC_END, at) : -1;
319
+ if (open && end !== -1 && (begin === -1 || end < begin)) {
320
+ const text = stripAnsiComplete(buf.slice(0, Math.min(end, FRAME_RAW_MAX))).replace(/\s+/g, ' ').trim();
321
+ if (isRealOutput(text)) {
322
+ const kept = !pane(text) && session.lastFrame ? `${session.lastFrame} ${text}` : text;
323
+ session.lastFrame = kept.length > 2 * FRAME_TEXT_MAX
324
+ ? `${kept.slice(0, FRAME_TEXT_MAX)} … ${kept.slice(-FRAME_TEXT_MAX)}`
325
+ : kept;
326
+ session.lastFrameAt = now;
327
+ }
328
+ buf = buf.slice(end + MARKER_LEN);
329
+ pos += end + MARKER_LEN;
330
+ open = false;
331
+ at = 0;
332
+ continue;
333
+ }
334
+ if (begin !== -1) {
335
+ if (!open) {
336
+ // Text before the marker is line text — the part of it in THIS read.
337
+ outside += buf.slice(Math.max(0, -pos), begin);
338
+ if (pos + begin < 0) straddle = -(pos + begin); // the marker began in the previous read's tail
339
+ }
340
+ buf = buf.slice(begin + MARKER_LEN);
341
+ pos += begin + MARKER_LEN;
342
+ open = true;
343
+ session.frameOpenedAt = now;
344
+ at = 0;
345
+ continue;
346
+ }
347
+ break;
348
+ }
349
+ if (open) {
350
+ // Text past the cap is dropped, markers are not: the body keeps its last
351
+ // few bytes past the cap, so a marker straddling the next read is still
352
+ // found and the frame still closes.
353
+ session.frameOpen = buf.length > FRAME_RAW_MAX
354
+ ? buf.slice(0, FRAME_RAW_MAX - (MARKER_LEN - 1)) + buf.slice(-(MARKER_LEN - 1))
355
+ : buf;
356
+ session.frameTail = '';
357
+ } else {
358
+ outside += buf.slice(Math.max(0, -pos));
359
+ session.frameOpen = null;
360
+ session.frameTail = buf.slice(-(MARKER_LEN - 1));
361
+ }
362
+ return { outside, straddle };
363
+ }
364
+
365
+ export function detectState(session, { now, stateTimeoutMs, promptPatterns, messagePatterns } = {}) {
366
+ const timestamp = now ?? Date.now();
367
+ const timeout = stateTimeoutMs ?? STATE_TIMEOUT_MS;
368
+ const prompts = promptPatterns ?? PROMPT_PATTERNS;
369
+ const messages = messagePatterns ?? MESSAGE_PATTERNS;
370
+
371
+ const timeSinceOutput = timestamp - session.lastOutputAt;
372
+ if (session.exited) return 'DISCONNECTED';
373
+ if (timeSinceOutput < timeout) return 'WORKING';
374
+ const lastLine = session.lastStrippedLine || '';
375
+ for (const pattern of messages) {
376
+ if (pattern.test(lastLine)) return 'MESSAGE';
377
+ }
378
+ // A TUI that repaints inside synchronized-output frames (Codex does; Claude
379
+ // Code does not) tells us what is on screen NOW: its last frame. That is the
380
+ // dialog when one is open, and the bare prompt once it is answered. The
381
+ // five-line window below is for everything else — and is exactly what went
382
+ // stale for Codex, which redraws with cursor moves rather than newlines, so
383
+ // an answered picker's text sat in the window until five real lines pushed
384
+ // it out, with the bubble showing the whole time.
385
+ //
386
+ // The newer source speaks. A frame older than the last whole LINE printed
387
+ // outside one is a program that stopped painting in frames — a shell tab
388
+ // that ran one such tool and then launched Claude Code — and the window is
389
+ // current again. Whole lines, not bytes: while a dialog waits, Codex writes
390
+ // its window title outside any frame every second, which strips to nothing
391
+ // whole but to a remnant when a read boundary falls inside it, and a
392
+ // remnant must not outrank the dialog. (A tie goes to the frame.)
393
+ const frame = typeof session.lastFrame === 'string' && (session.lastFrameAt || 0) >= (session.lastLineAt || 0)
394
+ ? session.lastFrame : '';
395
+ if (frame) {
396
+ for (const pattern of messages) {
397
+ if (pattern.test(frame)) return 'MESSAGE';
398
+ }
399
+ } else {
400
+ for (const line of session.recentStrippedLines) {
401
+ for (const pattern of messages) {
402
+ if (pattern.test(line)) return 'MESSAGE';
403
+ }
404
+ }
405
+ }
406
+ for (const pattern of prompts) {
407
+ if (pattern.test(lastLine)) return 'WAITING';
408
+ }
409
+ if (session.isTUI) return 'WAITING';
410
+ return 'IDLE';
411
+ }
412
+
413
+ // A leading ~/ as the home directory, the way a shell would read it.
414
+ export function expandHome(path, home = homedir()) {
415
+ const raw = String(path ?? '').trim();
416
+ return raw.startsWith('~/') ? resolve(home, raw.slice(2)) : raw;
417
+ }
418
+
419
+ // Split a command string into { file, args } with shell-like quoting so
420
+ // commands with spaces survive (e.g. bash -lc "echo hi; ls"). Handles single
421
+ // quotes, double quotes, and backslash escapes. Naive split(/\s+/) broke any
422
+ // command that needed a quoted argument.
423
+ export function parseCommand(command) {
424
+ const tokens = [];
425
+ let current = '';
426
+ let hasToken = false;
427
+ let inSingle = false;
428
+ let inDouble = false;
429
+ for (let i = 0; i < command.length; i++) {
430
+ const c = command[i];
431
+ if (inSingle) {
432
+ if (c === "'") inSingle = false;
433
+ else current += c;
434
+ hasToken = true;
435
+ } else if (inDouble) {
436
+ if (c === '"') inDouble = false;
437
+ else if (c === '\\' && (command[i + 1] === '"' || command[i + 1] === '\\')) current += command[++i];
438
+ else current += c;
439
+ hasToken = true;
440
+ } else if (c === "'") {
441
+ inSingle = true; hasToken = true;
442
+ } else if (c === '"') {
443
+ inDouble = true; hasToken = true;
444
+ } else if (c === '\\' && i + 1 < command.length) {
445
+ current += command[++i]; hasToken = true;
446
+ } else if (/\s/.test(c)) {
447
+ if (hasToken) { tokens.push(current); current = ''; hasToken = false; }
448
+ } else {
449
+ current += c; hasToken = true;
450
+ }
451
+ }
452
+ if (hasToken) tokens.push(current);
453
+ return { file: tokens[0] || '', args: tokens.slice(1) };
454
+ }
455
+
456
+ // The environment every pty gets. A server started by launchd, systemd or a
457
+ // bare ssh command has no locale, so its terminals ran in POSIX: readline,
458
+ // vim and tmux mangled UTF-8, and ssh (which forwards LANG) carried the same
459
+ // blindness to the remote host. A UTF-8 LANG fills in only when none is set.
460
+ export function ptyEnv(env) {
461
+ const hasLocale = env.LC_ALL || env.LC_CTYPE || env.LANG;
462
+ return { ...env, TERM: 'xterm-256color', ...(hasLocale ? {} : { LANG: 'en_US.UTF-8' }) };
463
+ }
464
+
465
+ export function parseGitStatus(output) {
466
+ const files = [];
467
+ for (const line of output.split('\n')) {
468
+ if (!line.trim()) continue;
469
+ const xy = line.slice(0, 2);
470
+ let path = line.slice(3);
471
+ if (xy === '!!') continue;
472
+ let status;
473
+ if (xy === '??') {
474
+ status = '?';
475
+ } else if (xy[0] === 'R' || xy[1] === 'R') {
476
+ status = 'R';
477
+ const arrow = path.indexOf(' -> ');
478
+ if (arrow !== -1) path = path.slice(arrow + 4);
479
+ } else if (xy[1] !== ' ') {
480
+ status = xy[1];
481
+ } else {
482
+ status = xy[0];
483
+ }
484
+ files.push({ path, status });
485
+ }
486
+ return files;
487
+ }
488
+
489
+ export function buildFileTree(files, repoName) {
490
+ const root = { name: repoName, children: [], type: 'dir' };
491
+ for (const { path, status } of files) {
492
+ const parts = path.split('/');
493
+ let current = root;
494
+ for (let i = 0; i < parts.length; i++) {
495
+ const name = parts[i];
496
+ if (i === parts.length - 1) {
497
+ current.children.push({ name, status, type: 'file', path });
498
+ } else {
499
+ let dir = current.children.find(c => c.name === name && c.type === 'dir');
500
+ if (!dir) {
501
+ dir = { name, children: [], type: 'dir' };
502
+ current.children.push(dir);
503
+ }
504
+ current = dir;
505
+ }
506
+ }
507
+ }
508
+ return root;
509
+ }
510
+
511
+ export function createRingBuffer(maxSize) {
512
+ const buffer = [];
513
+ return {
514
+ push(item) {
515
+ buffer.push(item);
516
+ if (buffer.length > maxSize) buffer.shift();
517
+ },
518
+ getAll() { return [...buffer]; },
519
+ get length() { return buffer.length; },
520
+ };
521
+ }
522
+
523
+ export function repoDirName(repoPath) {
524
+ const name = basename(repoPath).toLowerCase().replace(/[^a-z0-9-]/g, '-');
525
+ const hash = createHash('md5').update(repoPath).digest('hex').slice(0, 4);
526
+ return `${name}-${hash}`;
527
+ }
528
+
529
+ // One sanitiser for every file name a client hands the server (terminal
530
+ // uploads, job attachments). Only [A-Za-z0-9._-] survive, so no separator
531
+ // does, and a Windows device name (CON, NUL, COM1...) is prefixed so
532
+ // writeFileSync cannot open a device instead of a file.
533
+ export function safeFilename(name) {
534
+ const clean = String(name || '').replace(/[^a-zA-Z0-9._-]/g, '_');
535
+ return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(clean) ? `_${clean}` : clean;
536
+ }
537
+
538
+ // An on-by-default switch from the environment: off only for 0/false/off/no.
539
+ export function envSwitchOn(value) {
540
+ return !/^(0|false|off|no)$/i.test(String(value ?? '').trim());
541
+ }