@orkestrel/scaffold 0.0.101 → 0.0.102

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 (58) hide show
  1. package/dist/agents/skills/orkestrel-dispatch/scripts/brief.js +6 -14
  2. package/dist/agents/skills/orkestrel-dispatch/scripts/cite.js +41 -28
  3. package/dist/agents/skills/orkestrel-dispatch/scripts/helpers.js +5 -12
  4. package/dist/agents/skills/orkestrel-dispatch/scripts/launch.js +25 -13
  5. package/dist/agents/skills/orkestrel-dispatch/scripts/login.js +18 -25
  6. package/dist/agents/skills/orkestrel-dispatch/scripts/result.js +11 -9
  7. package/dist/agents/skills/orkestrel-harden/scripts/discovery.js +233 -232
  8. package/dist/agents/skills/orkestrel-publish/scripts/compare.js +95 -89
  9. package/dist/agents/skills/orkestrel-publish/scripts/pins.js +5 -7
  10. package/dist/agents/skills/orkestrel-publish/scripts/swap.js +1273 -0
  11. package/dist/agents/skills/orkestrel-publish/scripts/wave.js +99 -89
  12. package/dist/agents/skills/orkestrel-publish/scripts/window.js +54 -52
  13. package/dist/agents/skills/orkestrel-scout/scripts/map.js +160 -161
  14. package/dist/bin/main.js +483 -444
  15. package/dist/bin/main.js.map +1 -1
  16. package/dist/host/AGENTS.md +1 -0
  17. package/dist/host/agents/skills/orkestrel-dispatch/SKILL.md +10 -10
  18. package/dist/host/agents/skills/orkestrel-dispatch/scripts/brief.ts +8 -11
  19. package/dist/host/agents/skills/orkestrel-dispatch/scripts/cite.ts +58 -28
  20. package/dist/host/agents/skills/orkestrel-dispatch/scripts/helpers.ts +5 -10
  21. package/dist/host/agents/skills/orkestrel-dispatch/scripts/launch.ts +31 -12
  22. package/dist/host/agents/skills/orkestrel-dispatch/scripts/login.ts +28 -23
  23. package/dist/host/agents/skills/orkestrel-dispatch/scripts/result.ts +12 -10
  24. package/dist/host/agents/skills/orkestrel-harden/scripts/discovery.ts +318 -237
  25. package/dist/host/agents/skills/orkestrel-journey/references/statechart.md +2 -5
  26. package/dist/host/agents/skills/orkestrel-publish/SKILL.md +7 -6
  27. package/dist/host/agents/skills/orkestrel-publish/references/release.md +12 -10
  28. package/dist/host/agents/skills/orkestrel-publish/references/wave.md +12 -5
  29. package/dist/host/agents/skills/orkestrel-publish/scripts/compare.ts +124 -82
  30. package/dist/host/agents/skills/orkestrel-publish/scripts/pins.ts +5 -7
  31. package/dist/host/agents/skills/orkestrel-publish/scripts/swap.ts +1655 -0
  32. package/dist/host/agents/skills/orkestrel-publish/scripts/wave.ts +128 -97
  33. package/dist/host/agents/skills/orkestrel-publish/scripts/window.ts +70 -55
  34. package/dist/host/agents/skills/orkestrel-scout/scripts/map.ts +215 -155
  35. package/dist/host/claude/rules/architecture.md +2 -2
  36. package/dist/host/claude/rules/tests.md +8 -2
  37. package/dist/host/claude/rules/typescript.md +130 -0
  38. package/dist/host/claude/rules/workspace.md +30 -12
  39. package/dist/host/configs/helpers.ts +206 -133
  40. package/dist/host/configs/policy.ts +110 -105
  41. package/dist/host/guides/scaffold.md +244 -170
  42. package/dist/host/manifest.json +35 -29
  43. package/dist/host/tests/config.test.ts +1220 -633
  44. package/dist/host/tests/policy.test.ts +19 -0
  45. package/dist/host/tests/setupPolicy.ts +1669 -985
  46. package/dist/src/core/index.cjs +1595 -1686
  47. package/dist/src/core/index.cjs.map +1 -1
  48. package/dist/src/core/index.d.cts +202 -18
  49. package/dist/src/core/index.d.ts +202 -18
  50. package/dist/src/core/index.js +1590 -1687
  51. package/dist/src/core/index.js.map +1 -1
  52. package/dist/src/server/index.cjs +448 -392
  53. package/dist/src/server/index.cjs.map +1 -1
  54. package/dist/src/server/index.d.cts +1182 -1052
  55. package/dist/src/server/index.d.ts +1182 -1052
  56. package/dist/src/server/index.js +447 -395
  57. package/dist/src/server/index.js.map +1 -1
  58. package/package.json +2 -2
@@ -27,6 +27,7 @@ configs/ thin target wrappers around root Vite/TypeScript configuration
27
27
  - The browser surface is `src/browser`, `app/browser`, the journey, and the showcase; the styles surface is `src/styles` and its themes. An extension adds a face to a surface: the `vue` browser extension adds `src/vue` and `app/vue`, and a named styles extension adds `src/<name>`.
28
28
  - `src/vue` and each `src/<name>` may import `src/core` and `src/browser`. `app/vue` may import what `app/browser` may import, plus `app/browser` and `src/vue`. No extension face imports server code or another extension's face, with one exception: a styles extension that builds another styles extension's recreation for a utility library (`src/tailwindcss` building Bootstrap for Tailwind) may `@use` that extension's Sass partials, configured through their `_tokens.scss` switches, and imports none of its TypeScript.
29
29
  - Published source never imports private app code.
30
+ - The root `studies/` directory holds consumer study code, the TypeScript adapters and JSON declarations the Study CLI runs. It sits outside the environment axes, is never built or shipped, and no `src` or `app` file imports it.
30
31
  - Enforce boundaries with the toolchain (Oxlint import restrictions, scoped TypeScript projects, Vite graphs). Add no second parser for TypeScript, Oxlint, Vue, HTML, CSS, or Vite.
31
32
  - `tsconfig.json`, `vite.config.ts`, and each `*/types.ts` are their sources of truth. Keep structural files even when empty.
32
33
 
@@ -10,16 +10,16 @@ description: >-
10
10
 
11
11
  ## Scripts
12
12
 
13
- | Script | Does |
14
- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
15
- | `scripts/bench.ts` | `--cursor [--resolve]`, `--codex [--sandbox MODE]`, or `--claude`, each with `[--model ID] [--cap SECONDS]`: resolves the bench entry, reads its version and login, runs one bounded round trip into `tmp/<bench>/bench.jsonl`, and prints JSON with `live`; `--resolve` prints the Cursor entry alone. Exit 3 when the bench is dark. |
16
- | `scripts/brief.ts` | `--unit UNIT --lane units\|cursor\|codex\|claude [--subject TEXT]` writes the brief from `.agents/templates/brief.md` and never overwrites a launched one; `--check PATH` lists every slash-bearing path the brief names that does not resolve from the root. Exit 3 when one is missing. |
17
- | `scripts/launch.ts` | `--journal PATH --errors PATH --cap SECONDS [--status] -- COMMAND [ARGS...]` runs the command with no shell and closed stdin, kills it at the cap (its process tree on Windows), records `git status --porcelain` before and after with `--status`, prints a spawn line carrying the `pid` when the command starts, writes that pid to `<journal>.pid`, and ends the errors file with `exit=`. Exit 124 when capped. |
18
- | `scripts/login.ts` | `--codex [--cap SECONDS]` reads `codex login status`, runs `codex login --device-auth` journaled to `tmp/codex/login.log` when it is not logged in, prints the URL and the one-time code as they appear, and polls the status until it answers or the cap ends; `--claude` reads `claude auth status`. Exit 0 logged in, 3 not. |
19
- | `scripts/result.ts` | `--cursor\|--claude\|--codex JOURNAL [--out PATH]` prints the session id, the answer length, the run's `exit`, `capped`, and `durationMs` from the errors file, and the journal's `bytes`, `ageMs`, and `lastEvent`. Exit 3 when the journal carries no session or answer. |
20
- | `scripts/cite.ts` | `PATH [--root DIR] [--json]` lists every `file:line` citation in a report whose file does not exist or whose line is past the end. Exit 3 when one does not resolve. |
21
- | `scripts/helpers.ts` | What every skill script shares: `readOption`, `readOptions`, `readList`, `listFiles`, `readJsonObject`, `readString`, `readNumber`, `normalizeSlashes`, `resolveNpm`, `runNpm`. It runs nothing; import it with the `.ts` extension. |
22
- | `scripts/sweep.ts` | `--report` lists leftovers under `tmp/` and `.orkestrel/` by age and deletes nothing; `--tmp [--older-than MINUTES]` deletes stale launch files and refuses while a journal is changing; `--unit UNIT [--delete]` lists or deletes one accepted unit's brief, report, claims, journal, errors, status, pid, and last-message files. |
13
+ | Script | Does |
14
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
15
+ | `scripts/bench.ts` | `--cursor [--resolve]`, `--codex [--sandbox MODE]`, or `--claude`, each with `[--model ID] [--cap SECONDS]`: resolves the bench entry, reads its version and login, runs one bounded round trip into `tmp/<bench>/bench.jsonl`, and prints JSON with `live`; `--resolve` prints the Cursor entry alone. Exit 3 when the bench is dark. |
16
+ | `scripts/brief.ts` | `--unit UNIT --lane units\|cursor\|codex\|claude [--subject TEXT]` writes the brief from `.agents/templates/brief.md` and never overwrites a launched one; `--check PATH` lists every slash-bearing path the brief names that does not resolve from the root. Exit 3 when one is missing. |
17
+ | `scripts/launch.ts` | `--journal PATH --errors PATH --cap SECONDS [--status] -- COMMAND [ARGS...]` runs the command with no shell and closed stdin, kills it at the cap (its process tree on Windows), records `git status --porcelain` before and after with `--status`, prints a spawn line carrying the `pid` when the command starts, writes that pid to `<journal>.pid`, and ends the errors file with `exit=`. Exit 124 when capped; 64 on usage, including a cap above 2147483 seconds. |
18
+ | `scripts/login.ts` | `--codex [--cap SECONDS]` reads `codex login status`, runs `codex login --device-auth` journaled to `tmp/codex/login.log` when it is not logged in, prints the URL and the one-time code as they appear, and polls the status until it answers or the cap ends; `--claude` reads `claude auth status`. Exit 0 logged in, 3 not. |
19
+ | `scripts/result.ts` | `--cursor\|--claude\|--codex JOURNAL [--out PATH]` prints the session id, the answer length, the run's `exit`, `capped`, and `durationMs` from the errors file, and the journal's `bytes`, `ageMs`, and `lastEvent`. Exit 3 when the journal carries no session or answer. |
20
+ | `scripts/cite.ts` | `PATH [--root DIR] [--json]` lists every `file:line` citation in a report whose file does not exist or whose line is past the end. Exit 3 when one does not resolve. |
21
+ | `scripts/helpers.ts` | What every skill script shares: `readOption`, `readOptions`, `readList`, `listFiles`, `readJsonObject`, `readString`, `readNumber`, `normalizeSlashes`, `resolveNpm`, `runNpm`. It runs nothing; import it with the `.ts` extension. |
22
+ | `scripts/sweep.ts` | `--report` lists leftovers under `tmp/` and `.orkestrel/` by age and deletes nothing; `--tmp [--older-than MINUTES]` deletes stale launch files and refuses while a journal is changing; `--unit UNIT [--delete]` lists or deletes one accepted unit's brief, report, claims, journal, errors, status, pid, and last-message files. |
23
23
 
24
24
  ## Write the brief
25
25
 
@@ -38,20 +38,17 @@ function readsAsPath(token: string): boolean {
38
38
  }
39
39
 
40
40
  function extractPaths(content: string): readonly string[] {
41
- const found = new Set<string>()
42
- for (const match of content.matchAll(/`([^`\n]+)`/gu)) {
43
- const token = (match[1] ?? '').replace(/\\/gu, '/').replace(/:\d+(?:[-–]\d+)?$/u, '')
44
- if (readsAsPath(token)) found.add(token)
45
- }
41
+ const found = new Set(
42
+ [...content.matchAll(/`([^`\n]+)`/gu)]
43
+ .map((match) => (match[1] ?? '').replace(/\\/gu, '/').replace(/:\d+(?:[-–]\d+)?$/u, ''))
44
+ .filter(readsAsPath),
45
+ )
46
46
  return [...found].sort()
47
47
  }
48
48
 
49
- function resolveBriefPath(directory: string, unit: string): string {
50
- const base = join(directory, `${unit}-brief.md`)
51
- if (!existsSync(base)) return base
52
- let n = 2
53
- while (existsSync(join(directory, `${unit}-brief-${n}.md`))) n += 1
54
- return join(directory, `${unit}-brief-${n}.md`)
49
+ function resolveBriefPath(directory: string, unit: string, count = 1): string {
50
+ const path = join(directory, count === 1 ? `${unit}-brief.md` : `${unit}-brief-${count}.md`)
51
+ return existsSync(path) ? resolveBriefPath(directory, unit, count + 1) : path
55
52
  }
56
53
 
57
54
  function writeBrief(argv: readonly string[]): number {
@@ -24,15 +24,20 @@ interface Resolution {
24
24
  readonly reason: string | undefined
25
25
  }
26
26
 
27
+ interface Citation {
28
+ readonly citation: string
29
+ readonly file: string
30
+ readonly line: number
31
+ readonly end: number | undefined
32
+ }
33
+
27
34
  function indexBasenames(root: string): ReadonlyMap<string, readonly string[]> {
28
- const index = new Map<string, string[]>()
29
- for (const file of listFiles(root)) {
30
- const name = basename(file.path)
31
- const paths = index.get(name) ?? []
32
- paths.push(normalizeSlashes(relative(root, file.path)))
33
- index.set(name, paths)
34
- }
35
- return index
35
+ return new Map(
36
+ [...Map.groupBy(listFiles(root), (file) => basename(file.path))].map(([name, files]) => [
37
+ name,
38
+ files.map((file) => normalizeSlashes(relative(root, file.path))),
39
+ ]),
40
+ )
36
41
  }
37
42
 
38
43
  function countLines(path: string): number {
@@ -43,15 +48,25 @@ function countLines(path: string): number {
43
48
  return lines.at(-1) === '' ? lines.length - 1 : lines.length
44
49
  }
45
50
 
51
+ function resolveDirect(root: string, file: string): string | undefined {
52
+ const direct = resolve(root, normalizeSlashes(file))
53
+ return existsSync(direct) && statSync(direct).isFile() ? direct : undefined
54
+ }
55
+
56
+ // A bare filename that no root-relative file matches is the one citation the basename index serves.
57
+ function needsIndex(root: string, file: string): boolean {
58
+ return resolveDirect(root, file) === undefined && !/[/\\]/u.test(file)
59
+ }
60
+
46
61
  function resolveFile(
47
62
  root: string,
48
63
  file: string,
49
- index: () => ReadonlyMap<string, readonly string[]>,
64
+ index: ReadonlyMap<string, readonly string[]> | undefined,
50
65
  ): Resolution {
51
- const direct = resolve(root, normalizeSlashes(file))
52
- if (existsSync(direct) && statSync(direct).isFile()) return { path: direct, reason: undefined }
66
+ const direct = resolveDirect(root, file)
67
+ if (direct !== undefined) return { path: direct, reason: undefined }
53
68
  if (/[/\\]/u.test(file)) return { path: undefined, reason: 'file does not exist' }
54
- const candidates = index().get(basename(file)) ?? []
69
+ const candidates = index?.get(basename(file)) ?? []
55
70
  const only = candidates.length === 1 ? candidates[0] : undefined
56
71
  if (only !== undefined) return { path: resolve(root, only), reason: undefined }
57
72
  if (candidates.length === 0) return { path: undefined, reason: 'file does not exist' }
@@ -62,10 +77,9 @@ function judgeLines(
62
77
  path: string,
63
78
  line: number,
64
79
  end: number | undefined,
65
- counts: Map<string, number>,
80
+ counts: ReadonlyMap<string, number>,
66
81
  ): string | undefined {
67
82
  const total = counts.get(path) ?? countLines(path)
68
- counts.set(path, total)
69
83
  if (line < 1 || line > total) return `line ${line} is outside 1-${total}`
70
84
  if (end !== undefined && (end < line || end > total))
71
85
  return `range end ${end} is outside ${line}-${total}`
@@ -86,23 +100,39 @@ function main(argv: readonly string[]): number {
86
100
  console.error('usage: cite.ts PATH [--root DIR] [--json]')
87
101
  return 64
88
102
  }
89
- let index: ReadonlyMap<string, readonly string[]> | undefined
90
- const counts = new Map<string, number>()
91
- const unresolved: Unresolved[] = []
92
- let citations = 0
93
- for (const match of readFileSync(path, 'utf8').matchAll(CITATION)) {
94
- const file = match[1]
95
- const line = Number(match[2])
96
- const end = match[3] === undefined ? undefined : Number(match[3])
97
- if (file === undefined) continue
98
- citations += 1
99
- const resolved = resolveFile(root, file, () => (index ??= indexBasenames(root)))
103
+ const named = [...readFileSync(path, 'utf8').matchAll(CITATION)].flatMap((match): Citation[] =>
104
+ match[1] === undefined
105
+ ? []
106
+ : [
107
+ {
108
+ citation: match[0],
109
+ file: match[1],
110
+ line: Number(match[2]),
111
+ end: match[3] === undefined ? undefined : Number(match[3]),
112
+ },
113
+ ],
114
+ )
115
+ const citations = named.length
116
+ // The basename index walks the root, so it is built once and only when a citation needs it.
117
+ const index = named.some(({ file }) => needsIndex(root, file)) ? indexBasenames(root) : undefined
118
+ const resolutions = named.map((entry) => ({
119
+ entry,
120
+ resolved: resolveFile(root, entry.file, index),
121
+ }))
122
+ // Each resolved file is counted once, however many citations name it.
123
+ const counts = new Map(
124
+ [...new Set(resolutions.flatMap(({ resolved }) => resolved.path ?? []))].map((file) => [
125
+ file,
126
+ countLines(file),
127
+ ]),
128
+ )
129
+ const unresolved = resolutions.flatMap(({ entry, resolved }): Unresolved[] => {
100
130
  const reason =
101
131
  resolved.path === undefined
102
132
  ? (resolved.reason ?? 'file does not exist')
103
- : judgeLines(resolved.path, line, end, counts)
104
- if (reason !== undefined) unresolved.push({ citation: match[0], reason })
105
- }
133
+ : judgeLines(resolved.path, entry.line, entry.end, counts)
134
+ return reason === undefined ? [] : [{ citation: entry.citation, reason }]
135
+ })
106
136
  if (argv.includes('--json')) {
107
137
  console.log(JSON.stringify({ path, root, citations, unresolved }))
108
138
  } else {
@@ -69,12 +69,10 @@ export function readMissingFlags(
69
69
  * @returns The values in order, empty when the flag never appears.
70
70
  */
71
71
  export function readOptions(argv: readonly string[], name: string): readonly string[] {
72
- const values: string[] = []
73
- argv.forEach((argument, index) => {
72
+ return argv.flatMap((argument, index) => {
74
73
  const value = argv[index + 1]
75
- if (argument === name && value !== undefined && !value.startsWith('--')) values.push(value)
74
+ return argument === name && value !== undefined && !value.startsWith('--') ? [value] : []
76
75
  })
77
- return values
78
76
  }
79
77
 
80
78
  /**
@@ -87,12 +85,9 @@ export function readOptions(argv: readonly string[], name: string): readonly str
87
85
  export function readList(argv: readonly string[], name: string): readonly string[] {
88
86
  const index = argv.indexOf(name)
89
87
  if (index === -1) return []
90
- const values: string[] = []
91
- for (const argument of argv.slice(index + 1)) {
92
- if (argument.startsWith('--')) break
93
- values.push(argument)
94
- }
95
- return values
88
+ const rest = argv.slice(index + 1)
89
+ const end = rest.findIndex((argument) => argument.startsWith('--'))
90
+ return end === -1 ? rest : rest.slice(0, end)
96
91
  }
97
92
 
98
93
  /**
@@ -2,7 +2,8 @@
2
2
  // node .agents/skills/orkestrel-dispatch/scripts/launch.ts --journal tmp/codex/UNIT.jsonl --errors tmp/codex/UNIT.err --cap 1500 [--status] -- codex exec --json ...
3
3
  // The command's stdout goes to --journal and its stderr to --errors; stdin is closed. Arguments after
4
4
  // `--` pass to the command verbatim, so no shell quoting is involved. --cap is in seconds: when it
5
- // expires the process tree is killed and the errors file records `capped=true`. --status records
5
+ // expires the process tree is killed and the errors file records `capped=true`. A fractional cap
6
+ // truncates to whole milliseconds, with 1 ms the least. --status records
6
7
  // `git status --porcelain` before and after beside the journal, which is how a read-only lane's
7
8
  // containment is checked. Stdout carries up to two JSON lines. The spawn line
8
9
  // `{"pid","journal","errors"}` prints as soon as the command starts, and the pid, as decimal digits
@@ -12,12 +13,23 @@
12
13
  // Windows kills the process tree; any other host kills the command alone, not its descendants.
13
14
  // The launcher's own flags are read before `--` alone. Exit: the command's code; 124 when capped;
14
15
  // 127 when the command could not start (the errors file names why); 64 on usage, including a
15
- // journal and errors path that are the same file and a cap that is not a number of seconds.
16
+ // journal and errors path that are the same file and a cap that is not a number of seconds from 0
17
+ // to 2147483.
16
18
  import { spawn, spawnSync } from 'node:child_process'
17
19
  import { appendFileSync, closeSync, mkdirSync, openSync, rmSync, writeFileSync } from 'node:fs'
18
20
  import { dirname } from 'node:path'
19
21
  import { readMissingFlags, readOption } from './helpers.ts'
20
22
 
23
+ // The longest wait a Node timer holds is 2147483647 ms; floored to whole seconds, a longer cap would
24
+ // throw from `AbortSignal.timeout` after the spawn or clamp to 1 ms and kill the command at once.
25
+ const MAX_CAP_SECONDS = 2_147_483
26
+
27
+ // `AbortSignal.timeout` throws on a fractional delay, which `setTimeout` truncates and raises to 1 ms
28
+ // from below, so the cap takes that form before the command starts.
29
+ function computeCapMilliseconds(cap: number): number {
30
+ return Math.max(1, Math.trunc(cap * 1000))
31
+ }
32
+
21
33
  function readStatus(): string {
22
34
  const result = spawnSync('git', ['status', '--porcelain'], { encoding: 'utf8' })
23
35
  return result.status === 0 ? result.stdout : `git status failed: ${result.stderr}`
@@ -64,15 +76,17 @@ async function main(argv: readonly string[]): Promise<number> {
64
76
  journal === errors ||
65
77
  !Number.isFinite(cap) ||
66
78
  cap < 0 ||
79
+ cap > MAX_CAP_SECONDS ||
67
80
  command === undefined ||
68
81
  command === ''
69
82
  ) {
70
83
  console.error(
71
- 'usage: launch.ts --journal PATH --errors PATH --cap SECONDS [--status] -- COMMAND [ARGS...]; the journal and the errors file differ, and the cap is a number of seconds (0 for none)',
84
+ `usage: launch.ts --journal PATH --errors PATH --cap SECONDS [--status] -- COMMAND [ARGS...]; the journal and the errors file differ, and the cap is a number of seconds from 0 to ${String(MAX_CAP_SECONDS)} (0 for none)`,
72
85
  )
73
86
  return 64
74
87
  }
75
88
  const status = own.includes('--status')
89
+ const capMilliseconds = cap > 0 ? computeCapMilliseconds(cap) : undefined
76
90
  mkdirSync(dirname(journal), { recursive: true })
77
91
  mkdirSync(dirname(errors), { recursive: true })
78
92
  if (status) writeFileSync(`${journal}.status-before.txt`, readStatus())
@@ -85,16 +99,21 @@ async function main(argv: readonly string[]): Promise<number> {
85
99
  writeFileSync(`${journal}.pid`, `${child.pid}\n`)
86
100
  console.log(JSON.stringify({ pid: child.pid, journal, errors }))
87
101
  }
88
- let capped = false
89
- const timer =
90
- cap > 0
91
- ? setTimeout(() => {
92
- capped = true
93
- if (child.pid !== undefined) killTree(child.pid)
94
- }, cap * 1000)
95
- : undefined
102
+ // The signal aborts once the cap elapses, so whether it aborted is whether the cap ended the run.
103
+ // The timer cannot be cleared, so a cap that elapses after the child exited kills nothing: a
104
+ // reused pid is not this command's.
105
+ const deadline = capMilliseconds === undefined ? undefined : AbortSignal.timeout(capMilliseconds)
106
+ deadline?.addEventListener(
107
+ 'abort',
108
+ () => {
109
+ if (child.pid !== undefined && child.exitCode === null && child.signalCode === null) {
110
+ killTree(child.pid)
111
+ }
112
+ },
113
+ { once: true },
114
+ )
96
115
  const exit = await waitForExit(child)
97
- if (timer !== undefined) clearTimeout(timer)
116
+ const capped = deadline?.aborted === true
98
117
  closeSync(journalFd)
99
118
  closeSync(errorsFd)
100
119
  const durationMs = Date.now() - started
@@ -21,6 +21,12 @@ interface Status {
21
21
  readonly text: string
22
22
  }
23
23
 
24
+ interface Poll {
25
+ readonly url: string | undefined
26
+ readonly code: string | undefined
27
+ readonly live: boolean
28
+ }
29
+
24
30
  function readStatus(command: string, args: readonly string[]): Status {
25
31
  const result = spawnSync(command, args, { encoding: 'utf8', windowsHide: true })
26
32
  if (result.error !== undefined)
@@ -44,6 +50,27 @@ function waitFor(ms: number): Promise<void> {
44
50
  })
45
51
  }
46
52
 
53
+ async function pollLogin(
54
+ started: number,
55
+ capSeconds: number,
56
+ url?: string,
57
+ code?: string,
58
+ ): Promise<Poll> {
59
+ if (Date.now() - started >= capSeconds * 1000) return { url, code, live: false }
60
+ await waitFor(POLL_MS)
61
+ const text = existsSync(JOURNAL) ? readFileSync(JOURNAL, 'utf8') : ''
62
+ const nextUrl = url ?? text.match(URL_PATTERN)?.[0]
63
+ if (url === undefined && nextUrl !== undefined) console.error(`login: open ${nextUrl}`)
64
+ const nextCode = code ?? text.match(CODE_PATTERN)?.[1]
65
+ if (code === undefined && nextCode !== undefined) {
66
+ console.error(`login: enter the code ${nextCode}`)
67
+ }
68
+ if (readStatus('codex', ['login', 'status']).live) {
69
+ return { url: nextUrl, code: nextCode, live: true }
70
+ }
71
+ return pollLogin(started, capSeconds, nextUrl, nextCode)
72
+ }
73
+
47
74
  async function loginCodex(capSeconds: number): Promise<number> {
48
75
  const before = readStatus('codex', ['login', 'status'])
49
76
  if (before.live) {
@@ -57,29 +84,7 @@ async function loginCodex(capSeconds: number): Promise<number> {
57
84
  stdio: ['ignore', journal, journal],
58
85
  windowsHide: true,
59
86
  })
60
- let url: string | undefined
61
- let code: string | undefined
62
- let live = false
63
- while (Date.now() - started < capSeconds * 1000) {
64
- await waitFor(POLL_MS)
65
- const text = existsSync(JOURNAL) ? readFileSync(JOURNAL, 'utf8') : ''
66
- if (url === undefined) {
67
- url = text.match(URL_PATTERN)?.[0]
68
- if (url !== undefined) console.error(`login: open ${url}`)
69
- }
70
- if (code === undefined) {
71
- code = text.match(CODE_PATTERN)?.[1]
72
- if (code !== undefined) console.error(`login: enter the code ${code}`)
73
- }
74
- if (child.exitCode !== null && readStatus('codex', ['login', 'status']).live) {
75
- live = true
76
- break
77
- }
78
- if (child.exitCode === null && readStatus('codex', ['login', 'status']).live) {
79
- live = true
80
- break
81
- }
82
- }
87
+ const { url, code, live } = await pollLogin(started, capSeconds)
83
88
  if (child.exitCode === null && child.pid !== undefined) killTree(child.pid)
84
89
  closeSync(journal)
85
90
  console.log(
@@ -26,17 +26,19 @@ interface Errors {
26
26
  }
27
27
 
28
28
  function readEvents(path: string): ReadonlyArray<Record<string, unknown>> {
29
- const events: Array<Record<string, unknown>> = []
30
- for (const line of readFileSync(path, 'utf8').split(/\r\n|\n/)) {
31
- if (line.trim() === '') continue
32
- try {
33
- const parsed: unknown = JSON.parse(line)
34
- if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
35
- events.push(Object.fromEntries(Object.entries(parsed)))
29
+ return readFileSync(path, 'utf8')
30
+ .split(/\r\n|\n/)
31
+ .flatMap((line) => {
32
+ if (line.trim() === '') return []
33
+ try {
34
+ const parsed: unknown = JSON.parse(line)
35
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
36
+ ? [Object.fromEntries(Object.entries(parsed))]
37
+ : []
38
+ } catch {
39
+ return []
36
40
  }
37
- } catch {}
38
- }
39
- return events
41
+ })
40
42
  }
41
43
 
42
44
  function readAnswer(text: string | undefined): string | undefined {