@erclx/canon 5.78.0 → 5.79.0

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 (56) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/app-drive/REQUIREMENT.md +12 -0
  4. package/claude/skills/app-drive/SKILL.md +44 -8
  5. package/claude/skills/app-drive/references/config.md +18 -8
  6. package/claude/skills/app-drive/references/drive-record.md +4 -0
  7. package/claude/skills/auto-ship/REQUIREMENT.md +1 -1
  8. package/claude/skills/auto-ship/SKILL.md +5 -4
  9. package/claude/skills/auto-ship/references/draft-mark.md +33 -15
  10. package/claude/skills/auto-ship/references/failure-recovery.md +4 -2
  11. package/claude/skills/git-followup/SKILL.md +1 -0
  12. package/claude/skills/git-pr/REQUIREMENT.md +2 -0
  13. package/claude/skills/git-pr/SKILL.md +2 -0
  14. package/claude/skills/git-pr/references/final-command.md +34 -2
  15. package/claude/skills/git-pr/references/pr.md +1 -1
  16. package/claude/skills/git-ship/REQUIREMENT.md +2 -2
  17. package/claude/skills/git-ship/SKILL.md +16 -11
  18. package/claude/skills/git-ship/references/ci-watch.md +28 -4
  19. package/claude/skills/markdown-craft/references/markdown.md +4 -4
  20. package/claude/skills/plan-feature/references/plan.md +2 -0
  21. package/claude/skills/review-address/SKILL.md +2 -1
  22. package/claude/skills/review-pr/references/key-changes.md +2 -1
  23. package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
  24. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  25. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
  26. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  27. package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
  28. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
  29. package/claude/skills/role-worker/SKILL.md +1 -1
  30. package/claude/skills/task-board/SKILL.md +1 -0
  31. package/claude/skills/task-board/references/tasks.md +1 -0
  32. package/docs/workflow/ai-workflow.md +1 -1
  33. package/docs/workflow/parallel-features.md +2 -1
  34. package/package.json +1 -1
  35. package/src/commands/pr.ts +138 -15
  36. package/src/commands/sessions.ts +88 -6
  37. package/src/commands/tasks.ts +41 -2
  38. package/src/git/files.ts +5 -2
  39. package/src/indexes/frontmatter.ts +27 -6
  40. package/src/indexes/render.ts +21 -3
  41. package/src/markdown/colons.ts +14 -4
  42. package/src/markdown/heading-case.ts +9 -2
  43. package/src/pr/bijection.ts +8 -0
  44. package/src/pr/evidence.ts +38 -9
  45. package/src/pr/health.ts +63 -15
  46. package/src/pr/links.ts +160 -0
  47. package/src/pr/local.ts +17 -4
  48. package/src/pr/paths.ts +20 -2
  49. package/src/sessions/stop.ts +145 -0
  50. package/src/sessions/transfer/bundle.ts +1 -1
  51. package/src/take/encode.ts +16 -42
  52. package/src/take/frame-clock.ts +46 -0
  53. package/src/take/screencast.ts +114 -11
  54. package/src/tasks/holders.ts +198 -0
  55. package/src/tasks/validate.ts +148 -0
  56. /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
package/src/pr/health.ts CHANGED
@@ -1,18 +1,26 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
 
4
+ /** The key listing folders whose listeners skip the health paths. */
5
+ export const PAGE_ONLY_KEY = 'page-only'
6
+
4
7
  /** Where a project declares what `canon pr local` checks beyond the page. */
5
8
  export const LOCAL_CONFIG_REL = join('canon', 'config', 'local.toml')
6
9
 
7
10
  /**
8
- * The health paths a project declared. `none` is an absent file or an empty
9
- * `health = []`. `invalid` is a declaration that exists and cannot be used,
10
- * which refuses rather than falling back to the page-only probe, since that
11
- * fallback is the failure the declaration exists to catch.
11
+ * The health paths a project declared, with the folders whose listeners skip
12
+ * them. `none` is an absent file or an empty `health = []`. `invalid` is a
13
+ * declaration that exists and cannot be used, which refuses rather than
14
+ * falling back to the page-only probe, since that fallback is the failure the
15
+ * declaration exists to catch.
12
16
  */
13
17
  export type HealthDeclaration =
14
- | { readonly kind: 'paths'; readonly paths: readonly string[] }
15
- | { readonly kind: 'none' }
18
+ | {
19
+ readonly kind: 'paths'
20
+ readonly paths: readonly string[]
21
+ readonly pageOnly: readonly string[]
22
+ }
23
+ | { readonly kind: 'none'; readonly pageOnly: readonly string[] }
16
24
  | { readonly kind: 'invalid'; readonly reason: string }
17
25
 
18
26
  /**
@@ -28,9 +36,24 @@ function isLocalPath(entry: unknown): entry is string {
28
36
  )
29
37
  }
30
38
 
39
+ /**
40
+ * A repository-relative folder as whole segments, or undefined when the entry
41
+ * is not one. Absolute paths, drive letters, backslashes, and `..` refuse,
42
+ * since each would name a place outside the repository. A leading `./` and
43
+ * trailing slashes are dropped so the match compares segments alone.
44
+ */
45
+ function toFolder(entry: unknown): string | undefined {
46
+ if (typeof entry !== 'string') return undefined
47
+ if (entry.startsWith('/') || entry.includes('\\')) return undefined
48
+ if (/^[A-Za-z]:/.test(entry)) return undefined
49
+ const segments = entry.split('/').filter((s) => s !== '' && s !== '.')
50
+ if (segments.length === 0 || segments.includes('..')) return undefined
51
+ return segments.join('/')
52
+ }
53
+
31
54
  export function readHealthPaths(root: string): HealthDeclaration {
32
55
  const path = join(root, LOCAL_CONFIG_REL)
33
- if (!existsSync(path)) return { kind: 'none' }
56
+ if (!existsSync(path)) return { kind: 'none', pageOnly: [] }
34
57
 
35
58
  let parsed: Record<string, unknown>
36
59
  try {
@@ -42,15 +65,40 @@ export function readHealthPaths(root: string): HealthDeclaration {
42
65
  return { kind: 'invalid', reason: `${LOCAL_CONFIG_REL} is not valid TOML` }
43
66
  }
44
67
 
45
- const declared = parsed.health
46
- if (!Array.isArray(declared) || !declared.every(isLocalPath)) {
47
- return {
48
- kind: 'invalid',
49
- reason: `${LOCAL_CONFIG_REL} needs health as a list of paths, each starting with a single /`,
68
+ const hasHealth = 'health' in parsed
69
+ const hasPageOnly = PAGE_ONLY_KEY in parsed
70
+ if (!hasHealth && !hasPageOnly) return invalidHealth()
71
+
72
+ let paths: readonly string[] = []
73
+ if (hasHealth) {
74
+ const declared = parsed.health
75
+ if (!Array.isArray(declared) || !declared.every(isLocalPath)) {
76
+ return invalidHealth()
50
77
  }
78
+ paths = declared
51
79
  }
52
80
 
53
- return declared.length === 0
54
- ? { kind: 'none' }
55
- : { kind: 'paths', paths: declared }
81
+ let pageOnly: readonly string[] = []
82
+ if (hasPageOnly) {
83
+ const declared = parsed[PAGE_ONLY_KEY]
84
+ const folders = Array.isArray(declared) ? declared.map(toFolder) : []
85
+ if (!Array.isArray(declared) || folders.includes(undefined)) {
86
+ return {
87
+ kind: 'invalid',
88
+ reason: `${LOCAL_CONFIG_REL} needs ${PAGE_ONLY_KEY} as a list of folders relative to the repository, with no leading / and no ..`,
89
+ }
90
+ }
91
+ pageOnly = folders as string[]
92
+ }
93
+
94
+ return paths.length === 0
95
+ ? { kind: 'none', pageOnly }
96
+ : { kind: 'paths', paths, pageOnly }
97
+ }
98
+
99
+ function invalidHealth(): HealthDeclaration {
100
+ return {
101
+ kind: 'invalid',
102
+ reason: `${LOCAL_CONFIG_REL} needs health as a list of paths, each starting with a single /`,
103
+ }
56
104
  }
@@ -0,0 +1,160 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { findBacktickSpans } from '@/markdown/backticks'
3
+ import { KEY_CHANGES, LINE_SUFFIX, readSectionAt } from '@/pr/paths'
4
+
5
+ /** A Key changes link whose anchor hashes a path other than its label's. */
6
+ export interface Mislinked {
7
+ readonly path: string
8
+ readonly anchor: string
9
+ }
10
+
11
+ /**
12
+ * The fragment GitHub gives a file's diff in a pull request's Files tab, being
13
+ * `diff-` and the SHA-256 of the raw path. Read off a merged pull request's
14
+ * rendered page, so a path holding a space or a bracket hashes as written.
15
+ */
16
+ export function diffAnchor(path: string): string {
17
+ return `diff-${createHash('sha256').update(path).digest('hex')}`
18
+ }
19
+
20
+ interface Located {
21
+ readonly text: string
22
+ readonly offset: number
23
+ readonly whole: string
24
+ }
25
+
26
+ /** The section's text and where it starts, in the body with line endings normalized. */
27
+ function locateSection(body: string, title: string): Located | undefined {
28
+ const whole = body.replace(/\r\n/g, '\n')
29
+ const found = readSectionAt(whole, title)
30
+ return found === undefined ? undefined : { ...found, whole }
31
+ }
32
+
33
+ /** Blanks every code span, keeping offsets, so a bracket inside a path never reads as link syntax. */
34
+ function maskSpans(text: string): string {
35
+ let out = text
36
+ for (const span of findBacktickSpans(text)) {
37
+ out =
38
+ out.slice(0, span.start) +
39
+ ' '.repeat(span.end - span.start) +
40
+ out.slice(span.end)
41
+ }
42
+ return out
43
+ }
44
+
45
+ /**
46
+ * An inline link: a destination bare or in angle brackets, then an optional
47
+ * quoted title. A form this misses reads as no link, and a span inside it then
48
+ * gets a second link nested in the first.
49
+ */
50
+ const LINK =
51
+ /\[[^\]]*\]\(\s*(<[^>]*>|[^)\s]*)(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)/dg
52
+ const DIFF_DESTINATION = /#diff-[0-9a-f]{64}$/
53
+
54
+ interface Edit {
55
+ readonly start: number
56
+ readonly end: number
57
+ readonly text: string
58
+ }
59
+
60
+ /**
61
+ * Writes each code span in `## Key changes` that names a changed file as a link
62
+ * to that file's diff, keeping the span as the label.
63
+ *
64
+ * Only an exact changed path is linked. A partial spelling or a folder names no
65
+ * single row in the Files tab, and a link to a guessed row is worse than none.
66
+ *
67
+ * A span that is the whole label of an existing diff link has its destination
68
+ * rewritten, which is how a wrong anchor clears. A span anywhere else inside a
69
+ * link's brackets is left alone, since a link nested in a link breaks the
70
+ * bullet, and the rewrite stays idempotent.
71
+ *
72
+ * `filesUrl` is the pull request's `/files` address, absolute because the body
73
+ * becomes the squash commit message, where a relative link resolves nowhere.
74
+ */
75
+ export function linkKeyChanges(
76
+ body: string,
77
+ changed: readonly string[],
78
+ filesUrl: string,
79
+ ): string {
80
+ const located = locateSection(body, KEY_CHANGES)
81
+ if (located === undefined) return body
82
+
83
+ const { text } = located
84
+ const known = new Set(changed)
85
+ const links = [...maskSpans(text).matchAll(LINK)].map((match) => ({
86
+ start: match.index ?? 0,
87
+ end: (match.index ?? 0) + match[0].length,
88
+ destination: match[1] ?? '',
89
+ destinationStart: match.indices?.[1]?.[0] ?? 0,
90
+ }))
91
+
92
+ const edits: Edit[] = []
93
+ for (const span of findBacktickSpans(text)) {
94
+ const path = span.content.replace(LINE_SUFFIX, '')
95
+ if (!known.has(path)) continue
96
+ const target = `${filesUrl}#${diffAnchor(path)}`
97
+
98
+ const link = links.find((l) => l.start < span.start && span.end <= l.end)
99
+ if (link === undefined) {
100
+ edits.push({
101
+ start: span.start,
102
+ end: span.end,
103
+ text: `[${text.slice(span.start, span.end)}](${target})`,
104
+ })
105
+ continue
106
+ }
107
+
108
+ const wholeLabel =
109
+ span.start === link.start + 1 && text.startsWith('](', span.end)
110
+ if (!wholeLabel || !DIFF_DESTINATION.test(link.destination)) continue
111
+ edits.push({
112
+ start: link.destinationStart,
113
+ end: link.destinationStart + link.destination.length,
114
+ text: target,
115
+ })
116
+ }
117
+
118
+ let out = ''
119
+ let cursor = 0
120
+ for (const edit of edits) {
121
+ out += text.slice(cursor, edit.start) + edit.text
122
+ cursor = edit.end
123
+ }
124
+ out += text.slice(cursor)
125
+
126
+ return (
127
+ located.whole.slice(0, located.offset) +
128
+ out +
129
+ located.whole.slice(located.offset + text.length)
130
+ )
131
+ }
132
+
133
+ const DIFF_LINK = /\[(`+)([^`]+)\1\]\([^)\s]*#(diff-[0-9a-f]{64})\)/g
134
+
135
+ /**
136
+ * Every Key changes link whose label is a changed path and whose anchor hashes
137
+ * another path.
138
+ *
139
+ * A label that is no changed path, such as a bare file name or a function, is
140
+ * left unjudged: it can open the right file under a spelling this cannot map
141
+ * back to a row, and an accusation needs the row.
142
+ */
143
+ export function findMislinked(
144
+ body: string,
145
+ changed: readonly string[],
146
+ ): Mislinked[] {
147
+ const located = locateSection(body, KEY_CHANGES)
148
+ if (located === undefined) return []
149
+
150
+ const known = new Set(changed)
151
+ const out: Mislinked[] = []
152
+ for (const match of located.text.matchAll(DIFF_LINK)) {
153
+ const path = (match[2] ?? '').trim().replace(LINE_SUFFIX, '')
154
+ const anchor = match[3] ?? ''
155
+ if (known.has(path) && diffAnchor(path) !== anchor) {
156
+ out.push({ path, anchor })
157
+ }
158
+ }
159
+ return out
160
+ }
package/src/pr/local.ts CHANGED
@@ -45,6 +45,11 @@ async function firstFailingPath(
45
45
  return undefined
46
46
  }
47
47
 
48
+ /** Whether `cwd` is `folder` or inside it, compared on whole path segments. */
49
+ function isUnder(cwd: string, folder: string): boolean {
50
+ return cwd === folder || cwd.startsWith(`${folder}/`)
51
+ }
52
+
48
53
  /**
49
54
  * Finds the server this worktree is running: the lowest port whose listening
50
55
  * process runs inside the worktree, answers an HTTP probe, and passes every
@@ -53,11 +58,14 @@ async function firstFailingPath(
53
58
  * lower one is the page by convention. Health is read on the page's own port,
54
59
  * since that is the address a reviewer drives, and a page whose backend fails
55
60
  * refuses as `unhealthy` naming the lowest page port's first failing path.
61
+ * A port whose listener runs under a `pageOnly` folder, matched on whole
62
+ * segments, is found on the page probe alone.
56
63
  */
57
64
  export async function findLocalServer(
58
65
  worktreeRoot: string,
59
66
  runner: LocalRunner,
60
67
  healthPaths: readonly string[] = [],
68
+ pageOnly: readonly string[] = [],
61
69
  ): Promise<LocalResult> {
62
70
  const listeners = await runner.listListeners()
63
71
  if (listeners === undefined) {
@@ -65,17 +73,22 @@ export async function findLocalServer(
65
73
  }
66
74
 
67
75
  const cwdByPid = new Map<number, string | undefined>()
68
- const ports = new Set<number>()
76
+ const ports = new Map<number, boolean>()
77
+ const folders = pageOnly.map((folder) => `${worktreeRoot}/${folder}`)
69
78
  for (const { pid, port } of listeners) {
70
79
  if (!cwdByPid.has(pid)) cwdByPid.set(pid, await runner.cwdOf(pid))
71
80
  const cwd = cwdByPid.get(pid)
72
- if (cwd !== undefined && belongsTo(cwd, worktreeRoot)) ports.add(port)
81
+ if (cwd === undefined || !belongsTo(cwd, worktreeRoot)) continue
82
+ const skipsHealth = folders.some((folder) => isUnder(cwd, folder))
83
+ ports.set(port, (ports.get(port) ?? false) || skipsHealth)
73
84
  }
74
85
 
75
86
  let unhealthy: (HealthFailure & { readonly port: number }) | undefined
76
- for (const port of [...ports].sort((a, b) => a - b)) {
87
+ for (const [port, skipsHealth] of [...ports].sort(([a], [b]) => a - b)) {
77
88
  if (!(await runner.probe(port))) continue
78
- const failure = await firstFailingPath(port, healthPaths, runner)
89
+ const failure = skipsHealth
90
+ ? undefined
91
+ : await firstFailingPath(port, healthPaths, runner)
79
92
  if (failure === undefined) {
80
93
  return { kind: 'found', port, url: `http://localhost:${port}` }
81
94
  }
package/src/pr/paths.ts CHANGED
@@ -77,7 +77,7 @@ const HEADING = /^(#{1,6})\s+(.+?)\s*$/
77
77
  * A `file.ts:42` or `file.ts:42-58` suffix, which is a reader's click target
78
78
  * rather than part of the name.
79
79
  */
80
- const LINE_SUFFIX = /:\d+(?:-\d+)?$/
80
+ export const LINE_SUFFIX = /:\d+(?:-\d+)?$/
81
81
 
82
82
  /**
83
83
  * A span that carries a line suffix and nothing else, such as `:642` beside
@@ -214,6 +214,21 @@ function disclaimsChange(bullet: string): boolean {
214
214
  * reports rather than reading as an empty section.
215
215
  */
216
216
  export function readSection(body: string, title: string): string | undefined {
217
+ return readSectionAt(body, title)?.text
218
+ }
219
+
220
+ /**
221
+ * The section's text and the character offset it starts at, counted in the body
222
+ * with its line endings normalized to `\n`.
223
+ *
224
+ * The offset is what lets a caller rewrite the section in place. Searching the
225
+ * body for the text instead finds the first copy, so an identical block above
226
+ * the heading would take the edit.
227
+ */
228
+ export function readSectionAt(
229
+ body: string,
230
+ title: string,
231
+ ): { readonly text: string; readonly offset: number } | undefined {
217
232
  const lines = body.replace(/\r\n/g, '\n').split('\n')
218
233
  const wanted = title.toLowerCase()
219
234
 
@@ -246,7 +261,10 @@ export function readSection(body: string, title: string): string | undefined {
246
261
  out.push(line)
247
262
  }
248
263
 
249
- return out.join('\n')
264
+ const offset = lines
265
+ .slice(0, start)
266
+ .reduce((sum, line) => sum + line.length + 1, 0)
267
+ return { text: out.join('\n'), offset }
250
268
  }
251
269
 
252
270
  /**
@@ -0,0 +1,145 @@
1
+ import { closeSync, fstatSync, openSync, readSync } from 'node:fs'
2
+ import type { ResolvedSession } from '@/sessions/resolve'
3
+ import { locateTranscript, projectsDir } from '@/sessions/transcript'
4
+
5
+ export interface StopReading {
6
+ /**
7
+ * `usage-limit` means the last assistant entry is a limit stop, passing over
8
+ * a `<synthetic>` entry with no error, such as the one a client restart
9
+ * writes. `unknown` means the read failed or found a limit-worded entry of
10
+ * another shape, so the row may be stopped and nothing here can say.
11
+ */
12
+ readonly stopped: 'usage-limit' | 'unknown' | null
13
+ readonly stopResets: string | null
14
+ }
15
+
16
+ const NOT_STOPPED: StopReading = { stopped: null, stopResets: null }
17
+ const UNREADABLE: StopReading = { stopped: 'unknown', stopResets: null }
18
+
19
+ /**
20
+ * The largest measured distance from a final stop to the end of its file is
21
+ * under 4 KiB, while a live transcript reaches hundreds of megabytes, so the
22
+ * read stays positional and never loads the file.
23
+ */
24
+ const WINDOW_BYTES = 1024 * 1024
25
+
26
+ function readTail(path: string): {
27
+ readonly text: string
28
+ readonly cut: boolean
29
+ } {
30
+ const fd = openSync(path, 'r')
31
+ try {
32
+ const { size } = fstatSync(fd)
33
+ const length = Math.min(size, WINDOW_BYTES)
34
+ const start = size - length
35
+ const buffer = Buffer.alloc(length)
36
+ let filled = 0
37
+ while (filled < length) {
38
+ const read = readSync(fd, buffer, filled, length - filled, start + filled)
39
+ if (read === 0) break
40
+ filled += read
41
+ }
42
+ return {
43
+ text: buffer.subarray(0, filled).toString('utf8'),
44
+ cut: start > 0,
45
+ }
46
+ } finally {
47
+ closeSync(fd)
48
+ }
49
+ }
50
+
51
+ function parseEntry(line: string): Record<string, unknown> | null {
52
+ try {
53
+ const parsed: unknown = JSON.parse(line)
54
+ return typeof parsed === 'object' && parsed !== null
55
+ ? (parsed as Record<string, unknown>)
56
+ : null
57
+ } catch {
58
+ return null
59
+ }
60
+ }
61
+
62
+ function entryText(message: Record<string, unknown>): string {
63
+ if (!Array.isArray(message.content)) return ''
64
+ return message.content
65
+ .map((block: { text?: unknown } | null) =>
66
+ typeof block?.text === 'string' ? block.text : '',
67
+ )
68
+ .join('\n')
69
+ }
70
+
71
+ /**
72
+ * Walks back to the entry that decides the row. No published contract describes
73
+ * the stop entry, so this rests on a measured shape: `error: "rate_limit"`, a
74
+ * `<synthetic>` model, `isApiErrorMessage`, and text naming the limit and its
75
+ * reset.
76
+ *
77
+ * A generated entry with limit wording and any other shape reads `unknown`,
78
+ * since a release renaming the field or the type still parses cleanly and would
79
+ * otherwise read as a session that never stopped. A `<synthetic>` entry with no
80
+ * error and no limit wording is passed over, since a client restart writes one
81
+ * reading `No response requested.` with no model turn behind it.
82
+ */
83
+ function readEntries(text: string, cut: boolean): StopReading {
84
+ const lines = text.split('\n')
85
+ // The first line of a cut window usually starts mid-entry, and one that
86
+ // happens to parse cannot be told apart from it.
87
+ const first = cut ? 1 : 0
88
+
89
+ for (let index = lines.length - 1; index >= first; index -= 1) {
90
+ const entry = parseEntry(lines[index] ?? '')
91
+ if (entry === null || entry.isSidechain === true) continue
92
+
93
+ const message =
94
+ typeof entry.message === 'object' && entry.message !== null
95
+ ? (entry.message as Record<string, unknown>)
96
+ : {}
97
+ const synthetic = message.model === '<synthetic>'
98
+ const generated = synthetic || entry.isApiErrorMessage === true
99
+ const entryWords = generated ? entryText(message) : ''
100
+ const worded = /hit your .* limit/.test(entryWords)
101
+
102
+ if (entry.type !== 'assistant') {
103
+ if (worded) return UNREADABLE
104
+ continue
105
+ }
106
+
107
+ if (entry.error === 'rate_limit') {
108
+ return {
109
+ stopped: 'usage-limit',
110
+ stopResets: /resets (.+)/.exec(entryText(message))?.[1]?.trim() ?? null,
111
+ }
112
+ }
113
+ if (worded) return UNREADABLE
114
+ if (synthetic && entry.error === undefined) continue
115
+
116
+ return NOT_STOPPED
117
+ }
118
+
119
+ return cut ? UNREADABLE : NOT_STOPPED
120
+ }
121
+
122
+ /**
123
+ * Reads whether a usage limit stopped one roster row, from the tail of its
124
+ * transcript. A `busy` row reads nothing, since a turn is running and a resume
125
+ * may be in flight.
126
+ */
127
+ export function readStop(
128
+ row: Pick<ResolvedSession, 'status' | 'sessionId'>,
129
+ dir: string = projectsDir(),
130
+ ): StopReading {
131
+ if (row.status === 'busy') return NOT_STOPPED
132
+ if (row.sessionId === null) return UNREADABLE
133
+
134
+ const located = locateTranscript(row.sessionId, dir)
135
+ if (located === null) return UNREADABLE
136
+
137
+ let tail: ReturnType<typeof readTail>
138
+ try {
139
+ tail = readTail(located.transcript)
140
+ } catch {
141
+ return UNREADABLE
142
+ }
143
+
144
+ return readEntries(tail.text, tail.cut)
145
+ }
@@ -21,7 +21,7 @@ import {
21
21
  readSideFolder,
22
22
  readTranscript,
23
23
  transcriptFacts,
24
- } from '@/sessions/transfer/transcript'
24
+ } from '@/sessions/transcript'
25
25
 
26
26
  /** Bumped when a field changes meaning, so an older import refuses a newer bundle. */
27
27
  export const BUNDLE_FORMAT = 1
@@ -75,18 +75,24 @@ export function openFrameEncoder(out: string, fps: number): FrameEncoder {
75
75
  // Held so a failure while frames are still being written surfaces at close
76
76
  // rather than as an unhandled rejection.
77
77
  done.catch(() => {})
78
+ // Rejects once FFmpeg is gone, since a full pipe with no reader never drains.
79
+ const stopped = done.then(() => {
80
+ throw new Error('ffmpeg stopped reading frames')
81
+ })
82
+ stopped.catch(() => {})
83
+ // A write to the dead process's pipe fails here, and `stopped` reports it.
84
+ proc.stdin?.on('error', () => {})
78
85
 
79
86
  return {
80
- write: (jpeg) =>
81
- new Promise((resolve, reject) => {
82
- const stdin = proc.stdin
83
- if (!stdin || stdin.destroyed) {
84
- reject(new Error('ffmpeg stopped reading frames'))
85
- return
86
- }
87
- if (stdin.write(jpeg)) resolve()
88
- else stdin.once('drain', resolve)
89
- }),
87
+ write: async (jpeg) => {
88
+ const stdin = proc.stdin
89
+ if (!stdin || stdin.destroyed) return stopped
90
+ if (stdin.write(jpeg)) return
91
+ await Promise.race([
92
+ new Promise<void>((resolve) => stdin.once('drain', resolve)),
93
+ stopped,
94
+ ])
95
+ },
90
96
  close: async () => {
91
97
  proc.stdin?.end()
92
98
  await done
@@ -97,38 +103,6 @@ export function openFrameEncoder(out: string, fps: number): FrameEncoder {
97
103
  }
98
104
  }
99
105
 
100
- /**
101
- * Re-encodes a recorded clip to the same H.264 output at a constant `fps`, cut
102
- * at `ms`. Playwright's recorder pads its webm past the last frame by at least
103
- * a second, so the cut is what ends the clip where the take's clock stopped.
104
- * A shorter input stays as it is, since `-t` never extends one.
105
- */
106
- export async function transcode(
107
- input: string,
108
- out: string,
109
- fps: number,
110
- ms: number,
111
- ): Promise<void> {
112
- const proc = spawn(
113
- 'ffmpeg',
114
- [
115
- '-y',
116
- '-loglevel',
117
- 'error',
118
- '-i',
119
- input,
120
- '-t',
121
- (ms / 1000).toFixed(3),
122
- '-r',
123
- String(fps),
124
- ...OUTPUT,
125
- out,
126
- ],
127
- { stdio: ['ignore', 'ignore', 'pipe'] },
128
- )
129
- await finished(proc)
130
- }
131
-
132
106
  /**
133
107
  * Counts a clip's frames, which a real-time take cannot know in advance and a
134
108
  * camera track has to match one for one.
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Places a screencast's frames on the take's monotonic clock. Playwright stamps
3
+ * a frame in wall time, and a time service can step the wall clock mid-take, so
4
+ * a stamp is only ever read against the wall clock of the moment it arrives.
5
+ */
6
+
7
+ /**
8
+ * A frame's time on the monotonic clock. A step between the frame's swap and its
9
+ * arrival cannot be told apart from latency, so a forward one lands that frame
10
+ * early and a backward one is capped at the arrival, the latest it can be.
11
+ */
12
+ export function frameStamp(reading: {
13
+ readonly presentedWall: number
14
+ readonly wallNow: number
15
+ readonly monoNow: number
16
+ }): number {
17
+ const { presentedWall, wallNow, monoNow } = reading
18
+ return Math.min(monoNow, presentedWall - (wallNow - monoNow))
19
+ }
20
+
21
+ /**
22
+ * Which frame fills each slot of a clip, as indexes into `stamps`, which holds
23
+ * the frames in arrival order. A slot takes the newest frame stamped at or
24
+ * before it, and slot 0 takes the first frame whenever it was presented. The
25
+ * indexes never fall, so an early stamp cannot reorder the clip. Empty when no
26
+ * frame arrived.
27
+ */
28
+ export function slotFrames(
29
+ stamps: readonly number[],
30
+ clip: {
31
+ readonly startMs: number
32
+ readonly lengthMs: number
33
+ readonly frameMs: number
34
+ },
35
+ ): number[] {
36
+ if (stamps.length === 0) return []
37
+ const slots = Math.round(clip.lengthMs / clip.frameMs)
38
+ const filled: number[] = []
39
+ let at = 0
40
+ for (let slot = 0; slot < slots; slot += 1) {
41
+ const clock = clip.startMs + slot * clip.frameMs
42
+ while ((stamps[at + 1] ?? Infinity) <= clock) at += 1
43
+ filled.push(at)
44
+ }
45
+ return filled
46
+ }