@erclx/canon 5.77.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.
- package/CHANGELOG.md +43 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +12 -0
- package/claude/skills/app-drive/SKILL.md +44 -8
- package/claude/skills/app-drive/references/config.md +18 -8
- package/claude/skills/app-drive/references/drive-record.md +4 -0
- package/claude/skills/auto-ship/REQUIREMENT.md +1 -1
- package/claude/skills/auto-ship/SKILL.md +5 -4
- package/claude/skills/auto-ship/references/draft-mark.md +33 -15
- package/claude/skills/auto-ship/references/failure-recovery.md +4 -2
- package/claude/skills/auto-ship/references/ui-checklist.md +9 -1
- package/claude/skills/code-craft/REQUIREMENT.md +1 -1
- package/claude/skills/code-craft/SKILL.md +7 -0
- package/claude/skills/code-craft/references/adopted.md +8 -0
- package/claude/skills/git-followup/SKILL.md +1 -0
- package/claude/skills/git-pr/REQUIREMENT.md +2 -0
- package/claude/skills/git-pr/SKILL.md +2 -0
- package/claude/skills/git-pr/references/final-command.md +34 -2
- package/claude/skills/git-pr/references/pr.md +1 -1
- package/claude/skills/git-ship/REQUIREMENT.md +2 -2
- package/claude/skills/git-ship/SKILL.md +16 -11
- package/claude/skills/git-ship/references/ci-watch.md +28 -4
- package/claude/skills/markdown-craft/references/markdown.md +4 -4
- package/claude/skills/plan-feature/references/plan.md +2 -0
- package/claude/skills/pr-evidence/SKILL.md +6 -1
- package/claude/skills/pr-evidence/references/evidence.md +1 -0
- package/claude/skills/pr-evidence/references/reel.md +9 -1
- package/claude/skills/review-address/SKILL.md +2 -1
- package/claude/skills/review-craft/references/ui.md +13 -1
- package/claude/skills/review-pr/references/body-variants.md +2 -0
- package/claude/skills/review-pr/references/close-out.md +2 -0
- package/claude/skills/review-pr/references/key-changes.md +2 -1
- package/claude/skills/review-pr/references/post.md +1 -0
- package/claude/skills/review-ui/REQUIREMENT.md +1 -0
- package/claude/skills/review-ui/SKILL.md +2 -2
- package/claude/skills/review-ui/references/driving.md +32 -4
- package/claude/skills/review-ui/references/post.md +6 -2
- package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
- package/claude/skills/role-orchestrator/SKILL.md +4 -4
- package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
- package/claude/skills/role-orchestrator/references/orchestrator-output.md +24 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +3 -2
- package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +35 -4
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
- package/claude/skills/role-worker/SKILL.md +2 -1
- package/claude/skills/task-board/SKILL.md +1 -0
- package/claude/skills/task-board/references/tasks.md +1 -0
- package/claude/skills/test-craft/SKILL.md +1 -0
- package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
- package/claude/skills/ui-checklist/SKILL.md +2 -1
- package/claude/skills/ui-craft/references/capture.md +16 -0
- package/claude/skills/ui-film/REQUIREMENT.md +2 -2
- package/claude/skills/ui-film/SKILL.md +6 -1
- package/claude/skills/ui-film/references/compose-craft.md +68 -11
- package/claude/skills/ui-film/references/config.md +2 -2
- package/claude/skills/ui-film/references/handoff.md +1 -1
- package/claude/skills/ui-film/references/harness.md +71 -0
- package/claude/skills/ui-film/references/take-craft.md +42 -17
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/operating-model.md +6 -5
- package/docs/workflow/parallel-features.md +2 -1
- package/docs/workflow/visual-design-workflow.md +2 -1
- package/governance/ledger/skills/code-craft/SKILL.md +9 -0
- package/governance/ledger/skills/test-craft/SKILL.md +1 -0
- package/governance/ledger/skills/ui-craft/capture.md +18 -5
- package/package.json +4 -3
- package/src/commands/pr.ts +255 -46
- package/src/commands/sessions.ts +88 -6
- package/src/commands/take.ts +12 -1
- package/src/commands/tasks.ts +41 -2
- package/src/design/components.ts +5 -107
- package/src/design/favicon.ts +21 -17
- package/src/design/render.ts +9 -27
- package/src/design/template.ts +42 -0
- package/src/design/templates/favicon.svg +17 -0
- package/src/design/templates/hand-drawn-figure.css +54 -0
- package/src/design/templates/preview.html +27 -0
- package/src/design/templates/scrollbar.css +29 -0
- package/src/design/templates/status.css +24 -0
- package/src/gate/embedded.ts +400 -0
- package/src/gate/stages.ts +8 -0
- package/src/git/files.ts +5 -2
- package/src/indexes/frontmatter.ts +27 -6
- package/src/indexes/render.ts +21 -3
- package/src/markdown/colons.ts +14 -4
- package/src/markdown/heading-case.ts +9 -2
- package/src/pr/bijection.ts +8 -0
- package/src/pr/evidence.ts +81 -13
- package/src/pr/frames.ts +1 -6
- package/src/pr/health.ts +63 -15
- package/src/pr/lift.ts +77 -9
- package/src/pr/links.ts +160 -0
- package/src/pr/local.ts +17 -4
- package/src/pr/paths.ts +20 -2
- package/src/pr/rest.ts +2 -0
- package/src/pr/review-scope.ts +28 -0
- package/src/pr/review-state.ts +5 -5
- package/src/pr/waiver.ts +45 -0
- package/src/sessions/stop.ts +145 -0
- package/src/sessions/transfer/bundle.ts +1 -1
- package/src/take/camera.ts +337 -208
- package/src/take/encode.ts +16 -42
- package/src/take/film.ts +7 -1
- package/src/take/frame-clock.ts +46 -0
- package/src/take/log.ts +43 -2
- package/src/take/perform.ts +85 -13
- package/src/take/plan.ts +26 -5
- package/src/take/reel.ts +30 -5
- package/src/take/screencast.ts +223 -13
- package/src/take/stepper.ts +24 -4
- package/src/tasks/holders.ts +198 -0
- package/src/tasks/validate.ts +148 -0
- package/src/commands/fixtures/gh.sh +0 -64
- package/src/take/fixtures/busy.html +0 -107
- package/src/take/fixtures/fade.html +0 -49
- package/src/take/fixtures/fetch.html +0 -38
- package/src/take/fixtures/input.html +0 -138
- package/src/take/zoom/NOTICE.md +0 -45
- package/src/take/zoom/math-utils.ts +0 -69
- package/src/take/zoom/region-utils.ts +0 -55
- package/src/take/zoom/spring.ts +0 -133
- /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
/**
|
package/src/pr/rest.ts
CHANGED
|
@@ -39,6 +39,7 @@ export interface RestReview {
|
|
|
39
39
|
readonly body?: string | null
|
|
40
40
|
readonly commit_id?: string | null
|
|
41
41
|
readonly submitted_at?: string | null
|
|
42
|
+
readonly state?: string | null
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/** One row of `repos/{owner}/{repo}/issues/<n>/comments`. */
|
|
@@ -117,6 +118,7 @@ export function reviewRowOf(review: RestReview): ReviewRow {
|
|
|
117
118
|
? null
|
|
118
119
|
: { oid: review.commit_id },
|
|
119
120
|
submittedAt: review.submitted_at ?? null,
|
|
121
|
+
...(typeof review.state === 'string' && { state: review.state }),
|
|
120
122
|
}
|
|
121
123
|
}
|
|
122
124
|
|
package/src/pr/review-scope.ts
CHANGED
|
@@ -47,6 +47,8 @@ export interface ReviewRow {
|
|
|
47
47
|
readonly body?: string
|
|
48
48
|
readonly commit?: { readonly oid?: string } | null
|
|
49
49
|
readonly submittedAt?: string | null
|
|
50
|
+
/** GitHub's review state, `DISMISSED` once the operator withdrew the pass. */
|
|
51
|
+
readonly state?: string | null
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
/**
|
|
@@ -238,6 +240,32 @@ export function resolveUiScope(listing: ReviewListing): UiScope {
|
|
|
238
240
|
}
|
|
239
241
|
}
|
|
240
242
|
|
|
243
|
+
/**
|
|
244
|
+
* The newest code pass and the newest UI pass, each only when its own marker
|
|
245
|
+
* closes it, skipping any pass the operator dismissed. A body with no pass
|
|
246
|
+
* heading never stands, and neither does an older pass a newer one replaced,
|
|
247
|
+
* so a pass speaks only for the head it read.
|
|
248
|
+
*/
|
|
249
|
+
export function standingPasses(reviews: readonly ReviewRow[]): ReviewRow[] {
|
|
250
|
+
let code: ReviewRow | undefined
|
|
251
|
+
let ui: ReviewRow | undefined
|
|
252
|
+
for (const row of reviews) {
|
|
253
|
+
if (row.state === 'DISMISSED') continue
|
|
254
|
+
const body = row.body ?? ''
|
|
255
|
+
if (headingOf(body) !== undefined) code = row
|
|
256
|
+
else if (resolveUiScope({ reviews: [row] }).state !== 'none') ui = row
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const passes: ReviewRow[] = []
|
|
260
|
+
if (code !== undefined && markerOf(code.body ?? '') !== undefined) {
|
|
261
|
+
passes.push(code)
|
|
262
|
+
}
|
|
263
|
+
if (ui !== undefined && UI_MARKER.test(lastLine(ui.body ?? '') ?? '')) {
|
|
264
|
+
passes.push(ui)
|
|
265
|
+
}
|
|
266
|
+
return passes
|
|
267
|
+
}
|
|
268
|
+
|
|
241
269
|
/**
|
|
242
270
|
* Whether a covered commit names the head. A marker may carry a short sha, so
|
|
243
271
|
* coverage is a prefix match on the head.
|
package/src/pr/review-state.ts
CHANGED
|
@@ -94,7 +94,7 @@ function familyOf(gate: GateResult, commit: string | undefined, tip: string) {
|
|
|
94
94
|
*/
|
|
95
95
|
async function readAt(
|
|
96
96
|
api: ReviewStateApi,
|
|
97
|
-
|
|
97
|
+
hasDrivableBox: boolean,
|
|
98
98
|
painted: PaintedPatterns,
|
|
99
99
|
): Promise<ReviewStateRead> {
|
|
100
100
|
const [tip, reviews] = await Promise.all([api.readTip(), api.readReviews()])
|
|
@@ -105,7 +105,7 @@ async function readAt(
|
|
|
105
105
|
const listing: { reviews: readonly ReviewRow[] } = { reviews }
|
|
106
106
|
const scope = resolveReviewScope(listing)
|
|
107
107
|
const uiScope = resolveUiScope(listing)
|
|
108
|
-
const ui = await uiGate(api, painted, reviews, tip,
|
|
108
|
+
const ui = await uiGate(api, painted, reviews, tip, hasDrivableBox)
|
|
109
109
|
if (ui.reason === 'gh-failed') return { kind: 'refused', reason: 'gh-failed' }
|
|
110
110
|
|
|
111
111
|
return {
|
|
@@ -117,7 +117,7 @@ async function readAt(
|
|
|
117
117
|
ui: {
|
|
118
118
|
...uiScope,
|
|
119
119
|
...familyOf(ui, uiScope.commit, tip),
|
|
120
|
-
isBound:
|
|
120
|
+
isBound: hasDrivableBox || uiScope.state === 'open',
|
|
121
121
|
...(ui.carriedFrom !== undefined && { carriedFrom: ui.carriedFrom }),
|
|
122
122
|
...(ui.paintedPaths !== undefined && { paintedPaths: ui.paintedPaths }),
|
|
123
123
|
...(ui.drivenPaths !== undefined && { drivenPaths: ui.drivenPaths }),
|
|
@@ -132,7 +132,7 @@ export async function readReviewState(
|
|
|
132
132
|
): Promise<ReviewStateRead> {
|
|
133
133
|
const evidence = await api.readEvidence()
|
|
134
134
|
if (evidence === undefined) return { kind: 'refused', reason: 'gh-failed' }
|
|
135
|
-
return readAt(api, evidence.
|
|
135
|
+
return readAt(api, evidence.hasDrivableBox, painted)
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
/**
|
|
@@ -158,7 +158,7 @@ export async function waitForReviews(
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
for (;;) {
|
|
161
|
-
const read = await readAt(api, evidence.
|
|
161
|
+
const read = await readAt(api, evidence.hasDrivableBox, painted)
|
|
162
162
|
if (read.kind === 'refused') {
|
|
163
163
|
return { ...read, waitedSeconds: waited() }
|
|
164
164
|
}
|
package/src/pr/waiver.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ReviewRow } from '@/pr/review-scope'
|
|
2
|
+
|
|
3
|
+
/** A painted path a review accepted as owing no capture, and why. */
|
|
4
|
+
export interface Waiver {
|
|
5
|
+
readonly path: string
|
|
6
|
+
readonly reason: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The line `review-pr` writes in a pass body to waive one path. The path is
|
|
11
|
+
* kept exactly as spelled, so a waiver matches an uncaptured path only when
|
|
12
|
+
* both spell it alike.
|
|
13
|
+
*/
|
|
14
|
+
const WAIVER_LINE = /^- \*\*Waived capture:\*\* `([^`]+)`:\s*(\S.*)$/
|
|
15
|
+
|
|
16
|
+
const FENCE = /^\s*(```|~~~)/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Every waiver in the review bodies, in order. A line inside a fenced block is
|
|
20
|
+
* a quoted sample rather than a waiver, so it never counts.
|
|
21
|
+
*
|
|
22
|
+
* Review bodies alone are read, never the pull request body or an issue
|
|
23
|
+
* comment, since every session posts as one account and a review is the one
|
|
24
|
+
* surface a worker never writes.
|
|
25
|
+
*/
|
|
26
|
+
export function readWaivers(reviews: readonly ReviewRow[]): Waiver[] {
|
|
27
|
+
const waivers: Waiver[] = []
|
|
28
|
+
for (const review of reviews) {
|
|
29
|
+
let fence: string | undefined
|
|
30
|
+
for (const line of (review.body ?? '').split('\n')) {
|
|
31
|
+
const opener = FENCE.exec(line)?.[1]
|
|
32
|
+
if (opener !== undefined) {
|
|
33
|
+
if (fence === undefined) fence = opener
|
|
34
|
+
else if (opener === fence) fence = undefined
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
if (fence !== undefined) continue
|
|
38
|
+
const match = WAIVER_LINE.exec(line.trimEnd())
|
|
39
|
+
if (match?.[1] !== undefined && match[2] !== undefined) {
|
|
40
|
+
waivers.push({ path: match[1], reason: match[2] })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return waivers
|
|
45
|
+
}
|
|
@@ -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/
|
|
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
|