@marcomigozzi/trailhead 0.15.0 → 0.16.1
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/README.it.md +52 -14
- package/README.md +53 -15
- package/bin/auto-intro-docs.test.js +72 -0
- package/bin/auto-merge-docs.test.js +33 -0
- package/bin/auto-merge-ticket-docs.test.js +32 -0
- package/bin/auto-review-rounds-docs.test.js +45 -0
- package/bin/auto-run-docs.test.js +60 -0
- package/bin/auto-scope-docs.test.js +33 -0
- package/bin/auto-slots-docs.test.js +45 -0
- package/bin/auto-uat-docs.test.js +47 -0
- package/bin/config-dir-templates-docs.test.js +52 -0
- package/bin/decision-builds-docs.test.js +75 -0
- package/bin/fog-vs-ticket-docs.test.js +56 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -0
- package/bin/lib/codex-projection.js +13 -6
- package/bin/lib/codex-projection.test.js +25 -2
- package/bin/lib/host-descriptor.js +14 -1
- package/bin/lib/host-descriptor.test.js +18 -0
- package/bin/lib/runtime-command-literals.test.js +184 -0
- package/bin/needs-you-docs.test.js +41 -0
- package/bin/pick-stress-test-docs.test.js +105 -0
- package/bin/report-status-docs.test.js +1 -1
- package/bin/trailhead.js +3 -3
- package/bin/trailhead.test.js +17 -18
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
- package/plugins/trailhead/agents/trailhead-verify.md +2 -2
- package/plugins/trailhead/commands/auto.md +2 -2
- package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
- package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
- package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
- package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
- package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
- package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.js +50 -21
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +50 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +68 -1
- package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +161 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
- package/plugins/trailhead/hooks/mod/README.md +185 -39
- package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
- package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
- package/plugins/trailhead/hooks/mod/map-pane.tsx +557 -99
- package/plugins/trailhead/hooks/mod/register.tsx +298 -39
- package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
- package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
- package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
- package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
- package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
- package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +336 -23
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +321 -0
- package/plugins/trailhead/skills/_shared/choices.md +7 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -5
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
- package/plugins/trailhead/skills/_shared/substrate.md +4 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +5 -5
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +2 -2
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +4 -3
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +23 -12
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -6
- package/plugins/trailhead/templates/statusline-trailhead.sh +80 -15
- package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
- package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
- package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
- package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
- package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +67 -3
- package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
- package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1839 -72
- package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
- package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
- package/plugins/trailhead/tests/session-registry.test.ts +50 -0
- package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
- package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
- package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
- package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
- package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- package/plugins/trailhead/types/index.d.ts +26 -1
|
@@ -6,26 +6,47 @@
|
|
|
6
6
|
import { atom, read, update } from 'claude-code'
|
|
7
7
|
import type { EngineInterface, Register } from 'claude-code'
|
|
8
8
|
|
|
9
|
-
import type { PromptSnapshot, WorkState } from '../../types'
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
9
|
+
import type { AutoRun, PromptSnapshot, RepoPin, RunCounts, WorkState } from '../../types'
|
|
10
|
+
import { RESUME_HINT, runLabel, showsResumeHint } from './auto-run-view'
|
|
11
|
+
import { clearRunCounts, readRunCounts, scanRun } from './auto-run'
|
|
12
|
+
import { followWhiteboard, onPinMove, registerMapPane } from './map-pane'
|
|
13
|
+
import { pinKey, pinPath, refreshPin } from './repo-pin'
|
|
14
|
+
import { isMainTurnRunning, markTurnComplete, markTurnStart } from './turn-state'
|
|
15
|
+
import { isForeignState, isOwnState, locateOwnState, noteFound, wasFoundElsewhere } from './work-state-locate'
|
|
16
|
+
import { sessionLiveness } from './session-registry'
|
|
17
|
+
import { promptSection, snapshotKey } from './work-state-prompt'
|
|
18
|
+
import { ELSEWHERE, fit, isMissing, nextOwnTicket, parseWorkState, pipeline, sameValue, statusLine } from './work-state-view'
|
|
13
19
|
import type { StageStatus } from './work-state-view'
|
|
14
20
|
|
|
15
21
|
export const workState = atom({ plugin: 'trailhead', key: 'workState' } as const, null as WorkState | null)
|
|
22
|
+
// A foreign state is "orphaned" (not this session's, nothing in play); `isElsewhere` says the session
|
|
23
|
+
// that wrote it is not known gone, so the wording is neutral and offers no resume.
|
|
16
24
|
export const isOrphaned = atom({ plugin: 'trailhead', key: 'isOrphaned' } as const, false)
|
|
25
|
+
export const isElsewhere = atom({ plugin: 'trailhead', key: 'isElsewhere' } as const, false)
|
|
17
26
|
// The ticket this session works, kept across a work-state file other sessions share.
|
|
18
27
|
export const ownTicket = atom({ plugin: 'trailhead', key: 'ownTicket' } as const, null as number | null)
|
|
19
28
|
export const promptSnapshot = atom({ plugin: 'trailhead', key: 'promptSnapshot' } as const, null as PromptSnapshot | null)
|
|
29
|
+
// The repo pin refreshPin keeps (repo-pin.ts); the module scan wants the atom spelled in the file that reads it,
|
|
30
|
+
// so this copy and the one in repo-pin.ts must keep the same { plugin, key }.
|
|
31
|
+
const repoPin = atom({ plugin: 'trailhead', key: 'repoPin' } as const, null as RepoPin | null)
|
|
32
|
+
|
|
33
|
+
// L'ultimo run trovato accanto al main checkout, i suoi conteggi di scope e il flag di ripresa (Esc o compattazione).
|
|
34
|
+
export const autoRun = atom({ plugin: 'trailhead', key: 'autoRun' } as const, null as AutoRun | null)
|
|
35
|
+
export const runCounts = atom({ plugin: 'trailhead', key: 'runCounts' } as const, null as RunCounts | null)
|
|
36
|
+
export const autoInterrupted = atom({ plugin: 'trailhead', key: 'autoInterrupted' } as const, false)
|
|
20
37
|
|
|
21
38
|
const STATE_PATH = '.trailhead/work-state.json'
|
|
22
39
|
const HELPER = 'trailhead-work-state.js'
|
|
23
40
|
const ACCENT = '#7aa2f7'
|
|
24
41
|
const GREEN = '#9ece6a'
|
|
25
42
|
const AMBER = '#e0af68'
|
|
43
|
+
const AUTO_COMMAND = 'trailhead:auto'
|
|
26
44
|
const SECTION_ID = 'trailhead:work-state'
|
|
45
|
+
const STALE_NOTE =
|
|
46
|
+
'No trailhead ticket is in play any more: the earlier `# trailhead work state` section of the system prompt is stale.'
|
|
27
47
|
|
|
28
48
|
type Dollar = EngineInterface
|
|
49
|
+
type RefreshInit = { isStart?: boolean; fresh?: boolean }
|
|
29
50
|
|
|
30
51
|
// The status text last pinned; the sentinel makes the first refresh set or clear it.
|
|
31
52
|
// (A hot reload resets it, which only costs one repeated status call.)
|
|
@@ -34,8 +55,7 @@ let shownStatus: string | undefined | typeof UNSET = UNSET
|
|
|
34
55
|
// Refreshes run one after the other, so two events never interleave their writes.
|
|
35
56
|
let queue: Promise<void> = Promise.resolve()
|
|
36
57
|
|
|
37
|
-
function pin($: Dollar,
|
|
38
|
-
const text = statusLine(state, orphaned)
|
|
58
|
+
function pin($: Dollar, text: string | undefined) {
|
|
39
59
|
if (text === shownStatus) return
|
|
40
60
|
shownStatus = text
|
|
41
61
|
$.ui.status(text)
|
|
@@ -47,15 +67,114 @@ async function setWorkState($: Dollar, value: WorkState | null) {
|
|
|
47
67
|
if (!sameValue(current, value)) await update($, workState, () => value)
|
|
48
68
|
}
|
|
49
69
|
|
|
70
|
+
async function setRun($: Dollar, value: AutoRun | null) {
|
|
71
|
+
const current = await read($, autoRun)
|
|
72
|
+
if (!sameValue(current, value)) await update($, autoRun, () => value)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function setCounts($: Dollar, value: RunCounts | null) {
|
|
76
|
+
const current = await read($, runCounts)
|
|
77
|
+
if (!sameValue(current, value)) await update($, runCounts, () => value)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function setInterrupted($: Dollar, value: boolean) {
|
|
81
|
+
const current = await read($, autoInterrupted)
|
|
82
|
+
if (current !== value) await update($, autoInterrupted, () => value)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Lo status si ricalcola per ultimo, da tutti gli atom: stato, run, conteggi e suggerimento.
|
|
86
|
+
async function showStatus($: Dollar) {
|
|
87
|
+
const state = await read($, workState)
|
|
88
|
+
const run = await read($, autoRun)
|
|
89
|
+
const hint = showsResumeHint(await read($, autoInterrupted), run)
|
|
90
|
+
const elsewhere = await read($, isElsewhere)
|
|
91
|
+
pin($, statusLine(state, await read($, isOrphaned), { run, counts: await read($, runCounts), hint, elsewhere }))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function mainRootOf($: Dollar): Promise<string | null> {
|
|
95
|
+
try {
|
|
96
|
+
return (await $.session.repo())?.root ?? null
|
|
97
|
+
} catch {
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Il run dei checkout accanto al main (il main working tree, non quello della sessione); solo lettura.
|
|
103
|
+
async function settleRun($: Dollar, state: WorkState | null) {
|
|
104
|
+
let run: AutoRun | null = null
|
|
105
|
+
let found: RunCounts | null = null
|
|
106
|
+
try {
|
|
107
|
+
const pinned = await read($, repoPin)
|
|
108
|
+
const main = (await mainRootOf($)) ?? pinned?.root ?? null
|
|
109
|
+
if (main !== null) {
|
|
110
|
+
const scanned = await scanRun($, { main, pinned: pinned?.root ?? main, ownState: state })
|
|
111
|
+
run = scanned.run
|
|
112
|
+
if (run !== null && pinned !== null) found = await readRunCounts($, pinned, scanned.scope)
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
debug($, `trailhead mod: autonomous run scan failed: ${String(err)}`)
|
|
116
|
+
}
|
|
117
|
+
if (run === null) clearRunCounts()
|
|
118
|
+
await setRun($, run)
|
|
119
|
+
await setCounts($, run === null ? null : found)
|
|
120
|
+
}
|
|
121
|
+
|
|
50
122
|
async function setOrphaned($: Dollar, value: boolean) {
|
|
51
123
|
const current = await read($, isOrphaned)
|
|
52
124
|
if (current !== value) await update($, isOrphaned, () => value)
|
|
53
125
|
}
|
|
54
126
|
|
|
55
|
-
|
|
127
|
+
async function setElsewhere($: Dollar, value: boolean) {
|
|
128
|
+
const current = await read($, isElsewhere)
|
|
129
|
+
if (current !== value) await update($, isElsewhere, () => value)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Le sessioni chiuse da un /clear di questa: per loro lo stato e' orfano subito, senza guardare il registro.
|
|
133
|
+
const ended = new Set<string>()
|
|
134
|
+
|
|
135
|
+
// Il registro delle sessioni del motore (<config>/sessions/<pid>.json). Il pid si controlla solo dove c'e'
|
|
136
|
+
// /proc (Linux); altrove conta la presenza della voce. Ogni errore e' "unknown": mai orfano per un dubbio.
|
|
137
|
+
async function registryDir($: Dollar): Promise<string | null> {
|
|
138
|
+
const config = await $.env.get('CLAUDE_CONFIG_DIR')
|
|
139
|
+
if (config !== undefined && config !== '') return `${config}/sessions`
|
|
140
|
+
const home = await $.env.get('HOME')
|
|
141
|
+
return home === undefined || home === '' ? null : `${home}/.claude/sessions`
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function foreignIsGone($: Dollar, sessionId: string): Promise<boolean> {
|
|
145
|
+
try {
|
|
146
|
+
if (ended.has(sessionId)) return true
|
|
147
|
+
const dir = await registryDir($)
|
|
148
|
+
if (dir === null) return false
|
|
149
|
+
const hasProc = await $.fs.exists('/proc/self').catch(() => false)
|
|
150
|
+
const liveness = await sessionLiveness(dir, sessionId, {
|
|
151
|
+
list: async path => (await $.fs.list(path)).filter(e => e.kind === 'file').map(e => e.name),
|
|
152
|
+
read: path => $.fs.read(path) as Promise<string>,
|
|
153
|
+
pidAlive: async pid => (hasProc ? $.fs.exists(`/proc/${pid}`) : null),
|
|
154
|
+
})
|
|
155
|
+
return liveness === 'gone'
|
|
156
|
+
} catch (err) {
|
|
157
|
+
debug($, `trailhead mod: session registry lookup failed: ${String(err)}`)
|
|
158
|
+
return false
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// What the latch was last set to, per session, once the atom write succeeded. Overlapping calls may see the
|
|
163
|
+
// atom as it stood when they began, so the compare-and-set in changeNote trusts this copy first; a hot
|
|
164
|
+
// reload falls back to the atom.
|
|
165
|
+
const told = new Map<string, PromptSnapshot | null>()
|
|
166
|
+
|
|
167
|
+
async function latchedNow($: Dollar): Promise<PromptSnapshot | null> {
|
|
168
|
+
const session = await $.session.id()
|
|
169
|
+
return told.has(session) ? (told.get(session) as PromptSnapshot | null) : read($, promptSnapshot)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// null means "rebuild on the next compose"; written only when it changes. The mirror follows a
|
|
173
|
+
// successful write only, so a denied write leaves the latch (and the mirror) as they were.
|
|
56
174
|
async function setSnapshot($: Dollar, value: PromptSnapshot | null) {
|
|
57
175
|
const current = await read($, promptSnapshot)
|
|
58
176
|
if (!sameValue(current, value)) await update($, promptSnapshot, () => value)
|
|
177
|
+
told.set(await $.session.id(), value)
|
|
59
178
|
}
|
|
60
179
|
|
|
61
180
|
const debug = ($: Dollar, message: string) => {
|
|
@@ -66,35 +185,69 @@ const debug = ($: Dollar, message: string) => {
|
|
|
66
185
|
}
|
|
67
186
|
}
|
|
68
187
|
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
async function
|
|
188
|
+
// What the state says now: its key and section text, with the links following the pin.
|
|
189
|
+
// The links follow the pin, so a shell `cd` out of the repo never loses them; no pin only costs the links.
|
|
190
|
+
async function liveSnapshot($: Dollar): Promise<PromptSnapshot> {
|
|
191
|
+
const state = await read($, workState)
|
|
192
|
+
const orphaned = await read($, isOrphaned)
|
|
193
|
+
const pinned = await read($, repoPin)
|
|
194
|
+
const repo = pinned === null ? null : { host: pinned.host, slug: pinned.slug }
|
|
195
|
+
return { key: snapshotKey(state, orphaned, repo), text: promptSection(state, orphaned, repo) }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// The latch is what the model was told. Only the first persisting compose after a reset (latch null:
|
|
199
|
+
// session start, compaction, /clear) writes it; a later compose returns the latched text, so the system
|
|
200
|
+
// prompt stays what the engine already holds (it composes every turn but keeps sending the first one).
|
|
201
|
+
// After that only prompt.submit advances the latch. A non-persisting (/context) compose never writes.
|
|
202
|
+
async function currentSnapshot($: Dollar, persist: boolean): Promise<PromptSnapshot> {
|
|
203
|
+
const latched = await read($, promptSnapshot)
|
|
204
|
+
if (latched !== null) return latched
|
|
205
|
+
const live = await liveSnapshot($)
|
|
206
|
+
if (persist) await setSnapshot($, live)
|
|
207
|
+
return live
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The latch compare-and-set runs one at a time, so two overlapping prompts attach a change once.
|
|
211
|
+
let latchQueue: Promise<unknown> = Promise.resolve()
|
|
212
|
+
function serial<T>(task: () => Promise<T>): Promise<T> {
|
|
213
|
+
const run = latchQueue.then(task, task)
|
|
214
|
+
latchQueue = run.catch(() => undefined)
|
|
215
|
+
return run
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A change after the latch reaches the model as context on the next prompt, once per change; no latch
|
|
219
|
+
// yet means the compose will carry it. The latch moves to the live snapshot here; `previous` is what to
|
|
220
|
+
// put back if the prompt does not enter.
|
|
221
|
+
async function changeNote($: Dollar): Promise<{ note: string | null; previous: PromptSnapshot | null; live: PromptSnapshot | null }> {
|
|
222
|
+
const none = { note: null, previous: null, live: null }
|
|
223
|
+
const latched = await latchedNow($)
|
|
224
|
+
if (latched === null) return none
|
|
225
|
+
const live = await liveSnapshot($)
|
|
226
|
+
if (live.key === latched.key) return none
|
|
72
227
|
try {
|
|
73
|
-
|
|
228
|
+
await setSnapshot($, live)
|
|
74
229
|
} catch (err) {
|
|
75
|
-
|
|
76
|
-
|
|
230
|
+
// The latch stays where it was: the next prompt retries, and this one goes on without the note.
|
|
231
|
+
debug($, `trailhead mod: latch write failed: ${String(err)}`)
|
|
232
|
+
return none
|
|
77
233
|
}
|
|
234
|
+
const note = live.text !== null ? live.text : latched.text === null ? null : STALE_NOTE
|
|
235
|
+
return { note, previous: latched, live }
|
|
78
236
|
}
|
|
79
237
|
|
|
80
|
-
//
|
|
81
|
-
async function
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
const key = snapshotKey(state, orphaned)
|
|
85
|
-
const latched = await read($, promptSnapshot)
|
|
86
|
-
if (latched !== null && latched.key === key) return latched
|
|
87
|
-
const slug = state === null || orphaned ? null : await readSlug($)
|
|
88
|
-
const built = { key, text: promptSection(state, orphaned, slug) }
|
|
89
|
-
if (persist) await setSnapshot($, built)
|
|
90
|
-
return built
|
|
238
|
+
// A dropped prompt told the model nothing: put the latch back, unless something else moved it since.
|
|
239
|
+
async function restoreLatch($: Dollar, previous: PromptSnapshot, live: PromptSnapshot) {
|
|
240
|
+
const current = await latchedNow($)
|
|
241
|
+
if (current !== null && current.key === live.key) await setSnapshot($, previous)
|
|
91
242
|
}
|
|
92
243
|
|
|
93
|
-
async function apply($: Dollar, state: WorkState | null, orphaned: boolean) {
|
|
244
|
+
async function apply($: Dollar, state: WorkState | null, orphaned: boolean, elsewhere = false) {
|
|
94
245
|
const isStale = state !== null && orphaned
|
|
95
246
|
await setWorkState($, state)
|
|
96
247
|
await setOrphaned($, isStale)
|
|
97
|
-
|
|
248
|
+
await setElsewhere($, isStale && elsewhere)
|
|
249
|
+
await settleRun($, state)
|
|
250
|
+
await showStatus($)
|
|
98
251
|
}
|
|
99
252
|
|
|
100
253
|
// Decided from the state read before this refresh replaces it.
|
|
@@ -104,10 +257,39 @@ async function setOwnTicket($: Dollar, next: WorkState | null) {
|
|
|
104
257
|
if (value !== prev) await update($, ownTicket, () => value)
|
|
105
258
|
}
|
|
106
259
|
|
|
107
|
-
|
|
260
|
+
// The state text: the pinned checkout's file, whatever the session cwd is now; without a pin (no GitHub
|
|
261
|
+
// repo ever seen) it stays relative to the cwd, as before. A missing file there sends the lookup to the
|
|
262
|
+
// repo's other checkouts (#270): the engine writes the state in the ticket's own workspace. A clean miss
|
|
263
|
+
// rethrows the own one, so the caller sees what it saw before; a broken lookup, when the state was found
|
|
264
|
+
// elsewhere before, is a transient error that keeps the last state. Another session's state there gets
|
|
265
|
+
// the same lookup, but a miss or a failure leaves it shown (orphaned). `fresh` (a helper call just wrote
|
|
266
|
+
// the state) skips the lookup's no-state memory.
|
|
267
|
+
async function readStateText($: Dollar, init: RefreshInit): Promise<string> {
|
|
268
|
+
const pin = await refreshPin($, { isStart: init.isStart, onMove: moved => onPinMove($, moved) }).catch(() => null)
|
|
269
|
+
try {
|
|
270
|
+
const text = (await $.fs.read(pin === null ? STATE_PATH : pinPath(pin, STATE_PATH))) as string
|
|
271
|
+
if (pin === null) return text
|
|
272
|
+
const session = await $.session.id()
|
|
273
|
+
if (isOwnState(text, session)) noteFound({ key: pinKey(pin), root: pin.root })
|
|
274
|
+
else if (isForeignState(text, session)) {
|
|
275
|
+
const found = await locateOwnState($, { key: pinKey(pin), root: pin.root }, session, init.fresh === true)
|
|
276
|
+
if (found.kind === 'found') return found.text
|
|
277
|
+
}
|
|
278
|
+
return text
|
|
279
|
+
} catch (err) {
|
|
280
|
+
if (pin === null || !isMissing(err)) throw err
|
|
281
|
+
const key = pinKey(pin)
|
|
282
|
+
const found = await locateOwnState($, { key, root: pin.root }, await $.session.id(), init.fresh === true)
|
|
283
|
+
if (found.kind === 'found') return found.text
|
|
284
|
+
if (found.kind === 'failed' && wasFoundElsewhere(key, pin.root)) throw new Error('trailhead mod: work state lookup failed')
|
|
285
|
+
throw err
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function refresh($: Dollar, init: RefreshInit = {}) {
|
|
108
290
|
let text: string
|
|
109
291
|
try {
|
|
110
|
-
text =
|
|
292
|
+
text = await readStateText($, init)
|
|
111
293
|
} catch (err) {
|
|
112
294
|
// A missing file means no ticket in play; any other failure is transient: keep the last state.
|
|
113
295
|
if (isMissing(err)) {
|
|
@@ -120,11 +302,14 @@ async function refresh($: Dollar) {
|
|
|
120
302
|
await setOwnTicket($, state)
|
|
121
303
|
if (state === null) return apply($, null, false)
|
|
122
304
|
const session = await $.session.id()
|
|
123
|
-
|
|
305
|
+
const foreign = state.session !== null && state.session !== session
|
|
306
|
+
// Una sessione straniera e' "altrove" finche' il registro non conferma che e' finita.
|
|
307
|
+
const gone = foreign && (await foreignIsGone($, state.session as string))
|
|
308
|
+
return apply($, state, foreign, foreign && !gone)
|
|
124
309
|
}
|
|
125
310
|
|
|
126
|
-
function safeRefresh($: Dollar): Promise<void> {
|
|
127
|
-
queue = queue.then(() => refresh(
|
|
311
|
+
function safeRefresh($: Dollar, init: RefreshInit = {}): Promise<void> {
|
|
312
|
+
queue = queue.then(() => refresh($, init)).catch(err => {
|
|
128
313
|
try {
|
|
129
314
|
$.ui.log(`trailhead mod: work state refresh failed: ${String(err)}`, { to: 'debug' })
|
|
130
315
|
} catch {
|
|
@@ -134,13 +319,23 @@ function safeRefresh($: Dollar): Promise<void> {
|
|
|
134
319
|
return queue
|
|
135
320
|
}
|
|
136
321
|
|
|
322
|
+
async function resumeHintCleared($: Dollar) {
|
|
323
|
+
try {
|
|
324
|
+
await setInterrupted($, false)
|
|
325
|
+
await showStatus($)
|
|
326
|
+
} catch (err) {
|
|
327
|
+
debug($, `trailhead mod: resume hint reset failed: ${String(err)}`)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
137
331
|
// A /clear ends the session the state belongs to: show it as orphaned at once.
|
|
138
332
|
async function markOrphaned($: Dollar) {
|
|
139
333
|
const state = await read($, workState)
|
|
140
334
|
// A file with no session is never orphaned, /clear included.
|
|
141
335
|
if (state === null || state.session === null) return
|
|
142
336
|
await setOrphaned($, true)
|
|
143
|
-
|
|
337
|
+
await setElsewhere($, false)
|
|
338
|
+
await showStatus($)
|
|
144
339
|
}
|
|
145
340
|
|
|
146
341
|
type Look = { dim?: boolean; bold?: boolean; color?: string }
|
|
@@ -166,7 +361,8 @@ export const register: Register = on => {
|
|
|
166
361
|
|
|
167
362
|
on('session.start', async ($, e, next) => {
|
|
168
363
|
const result = await next(e)
|
|
169
|
-
await safeRefresh(
|
|
364
|
+
await safeRefresh($, { isStart: true })
|
|
365
|
+
await followWhiteboard($)
|
|
170
366
|
await setSnapshot($, null).catch(err => debug($, `trailhead mod: snapshot reset failed: ${String(err)}`))
|
|
171
367
|
return result
|
|
172
368
|
})
|
|
@@ -185,12 +381,41 @@ export const register: Register = on => {
|
|
|
185
381
|
}
|
|
186
382
|
})
|
|
187
383
|
|
|
384
|
+
// A change after the one compose reaches the model here; fail-open, the prompt always goes on.
|
|
385
|
+
on('prompt.submit', async ($, e, next) => {
|
|
386
|
+
if (e.command === AUTO_COMMAND) {
|
|
387
|
+
// Il run riprende: il suggerimento sparisce, a meno che il comando non sia stato scartato.
|
|
388
|
+
const result = await next(e)
|
|
389
|
+
if (result.drop === undefined) await resumeHintCleared($)
|
|
390
|
+
return result
|
|
391
|
+
}
|
|
392
|
+
if (e.command !== undefined) return next(e)
|
|
393
|
+
await safeRefresh($)
|
|
394
|
+
const { note, previous, live } = await serial(() => changeNote($))
|
|
395
|
+
const put = () => (previous === null || live === null ? Promise.resolve() : serial(() => restoreLatch($, previous, live)).catch(err => debug($, `trailhead mod: latch restore failed: ${String(err)}`)))
|
|
396
|
+
let result
|
|
397
|
+
try {
|
|
398
|
+
result = await next(note === null ? e : { ...e, context: [...(e.context ?? []), note] })
|
|
399
|
+
} catch (err) {
|
|
400
|
+
// Delivery is not confirmed: put the latch back so the next prompt carries the change.
|
|
401
|
+
await put()
|
|
402
|
+
throw err
|
|
403
|
+
}
|
|
404
|
+
if (result.drop !== undefined) await put()
|
|
405
|
+
return result
|
|
406
|
+
}).catch(($, e, next) => {
|
|
407
|
+
debug($, 'trailhead mod: work state context failed')
|
|
408
|
+
return next(e)
|
|
409
|
+
})
|
|
410
|
+
|
|
188
411
|
// After a compaction the agent re-reads the live state: refresh, then rebuild the snapshot.
|
|
189
412
|
on('session.compact', async ($, e, next) => {
|
|
190
413
|
const result = await next(e)
|
|
191
414
|
// A subagent's or a precomputed compaction installs nothing in the main conversation.
|
|
192
415
|
if (e.agentId !== undefined || e.trigger === 'precompute' || result.messages === undefined) return result
|
|
193
416
|
try {
|
|
417
|
+
// Una compattazione tra due turni interrompe il run; dentro un turno in corso no (il run prosegue).
|
|
418
|
+
if (!isMainTurnRunning()) await setInterrupted($, true)
|
|
194
419
|
await safeRefresh($)
|
|
195
420
|
await setSnapshot($, null)
|
|
196
421
|
} catch (err) {
|
|
@@ -200,33 +425,52 @@ export const register: Register = on => {
|
|
|
200
425
|
})
|
|
201
426
|
|
|
202
427
|
on('turn.start', async ($, e, next) => {
|
|
428
|
+
// Marked before the chain runs, so a press handled meanwhile already sees the turn.
|
|
429
|
+
if (e.agentId === undefined) markTurnStart()
|
|
203
430
|
const result = await next(e)
|
|
431
|
+
if (e.agentId === undefined) {
|
|
432
|
+
// Il run può proseguire da solo (il completamento di uno slot apre un nuovo turno).
|
|
433
|
+
await setInterrupted($, false).catch(err => debug($, `trailhead mod: resume hint reset failed: ${String(err)}`))
|
|
434
|
+
}
|
|
204
435
|
await safeRefresh($)
|
|
205
436
|
return result
|
|
206
437
|
})
|
|
207
438
|
|
|
208
439
|
on('turn.complete', async ($, e, next) => {
|
|
209
440
|
const result = await next(e)
|
|
441
|
+
if (e.agentId === undefined) {
|
|
442
|
+
markTurnComplete()
|
|
443
|
+
if (e.isAborted) await setInterrupted($, true).catch(err => debug($, `trailhead mod: resume hint set failed: ${String(err)}`))
|
|
444
|
+
}
|
|
210
445
|
await safeRefresh($)
|
|
446
|
+
await followWhiteboard($)
|
|
211
447
|
return result
|
|
212
448
|
})
|
|
213
449
|
|
|
214
450
|
// The helper runs through Bash: read again once the call has run.
|
|
215
451
|
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
|
|
216
452
|
const result = await next(e)
|
|
217
|
-
if (typeof e.command === 'string' && e.command.includes(HELPER))
|
|
453
|
+
if (typeof e.command === 'string' && e.command.includes(HELPER)) {
|
|
454
|
+
await safeRefresh($, { fresh: true })
|
|
455
|
+
await followWhiteboard($)
|
|
456
|
+
}
|
|
218
457
|
return result
|
|
219
458
|
// This hook never judges the call: if it fails, the call goes on untouched.
|
|
220
459
|
}).catch((_$, e, next) => next(e))
|
|
221
460
|
|
|
222
461
|
on('session.end', async ($, e, next) => {
|
|
462
|
+
// The session ends with no turn left running, whatever the reason.
|
|
463
|
+
markTurnComplete()
|
|
223
464
|
if (e.reason === 'clear') {
|
|
465
|
+
ended.add(e.sessionId)
|
|
224
466
|
try {
|
|
225
467
|
await markOrphaned($)
|
|
226
468
|
} catch {
|
|
227
469
|
// Never block the session ending.
|
|
228
470
|
}
|
|
229
471
|
}
|
|
472
|
+
// The ended session's mirror is never read again.
|
|
473
|
+
told.delete(e.sessionId)
|
|
230
474
|
return next(e)
|
|
231
475
|
})
|
|
232
476
|
|
|
@@ -242,15 +486,30 @@ export const register: Register = on => {
|
|
|
242
486
|
|
|
243
487
|
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
244
488
|
const state = await read($, workState)
|
|
245
|
-
const
|
|
246
|
-
|
|
489
|
+
const isStale = await read($, isOrphaned)
|
|
490
|
+
const elsewhere = isStale && (await read($, isElsewhere))
|
|
491
|
+
const orphaned = isStale && !elsewhere
|
|
492
|
+
const run = await read($, autoRun)
|
|
493
|
+
const counts = await read($, runCounts)
|
|
494
|
+
const hint = showsResumeHint(await read($, autoInterrupted), run)
|
|
495
|
+
if (e.props.hasSurvey) return next(e)
|
|
496
|
+
if (state === null) {
|
|
497
|
+
// Nessuno stato proprio (gli slot non hanno sessione): una riga sola per il run o per il suggerimento.
|
|
498
|
+
if (run === null) return next(e)
|
|
499
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
500
|
+
return (
|
|
501
|
+
<Box key="run">
|
|
502
|
+
<Text color={AMBER}>{hint ? RESUME_HINT : runLabel(run, counts)}</Text>
|
|
503
|
+
</Box>
|
|
504
|
+
)
|
|
505
|
+
}
|
|
247
506
|
|
|
248
507
|
const { Box, Text } = $.ui.resolve(e)
|
|
249
508
|
const { stages, isPaused } = pipeline(state.step)
|
|
250
509
|
const ticketLabel = `#${state.ticket}`
|
|
251
510
|
const room = e.props.bodyColumns - ticketLabel.length - 1
|
|
252
511
|
const title = state.title === null ? null : fit(state.title, room)
|
|
253
|
-
const tag = orphaned ? ' orphaned, resume?' : isPaused ? ' ‖ paused' : null
|
|
512
|
+
const tag = elsewhere ? ` ${ELSEWHERE}` : orphaned ? ' orphaned, resume?' : isPaused ? ' ‖ paused' : null
|
|
254
513
|
|
|
255
514
|
return (
|
|
256
515
|
<Box flexDirection="column">
|
|
@@ -281,9 +540,9 @@ export const register: Register = on => {
|
|
|
281
540
|
</Box>
|
|
282
541
|
)}
|
|
283
542
|
</Box>
|
|
284
|
-
{state.auto && !
|
|
543
|
+
{hint || (state.auto && !isStale) ? (
|
|
285
544
|
<Box key="auto">
|
|
286
|
-
<Text color={AMBER}>{
|
|
545
|
+
<Text color={AMBER}>{hint ? RESUME_HINT : runLabel(run ?? { done: state.autoDone }, counts)}</Text>
|
|
287
546
|
</Box>
|
|
288
547
|
) : null}
|
|
289
548
|
</Box>
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// The repo the session works in, pinned (#268): the map pane and the band read their files and run
|
|
2
|
+
// `gh` against it, so a shell `cd` out of the repo (the session cwd moves) never loses them.
|
|
3
|
+
// Pure parts first, then the engine part that keeps the pin in an atom.
|
|
4
|
+
import { atom, read, update } from 'claude-code'
|
|
5
|
+
import type { EngineInterface } from 'claude-code'
|
|
6
|
+
|
|
7
|
+
import type { RepoPin } from '../../types'
|
|
8
|
+
import { sameValue } from './work-state-view'
|
|
9
|
+
|
|
10
|
+
type RepoRead = { root: string; remote: string | null }
|
|
11
|
+
|
|
12
|
+
// Host and owner/name are kept to the characters a link may carry: they reach the system prompt.
|
|
13
|
+
const URL_REMOTE = /^(?:https?|ssh|git):\/\/(?:[^@/\s]+@)?([A-Za-z0-9.-]+)(?::\d+)?\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/i
|
|
14
|
+
const SCP_REMOTE = /^(?:[^@/\s]+@)?([A-Za-z0-9.-]+):([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/
|
|
15
|
+
|
|
16
|
+
// Host and owner/name of a remote, any host (github.com or an Enterprise one; `gh` resolves the host
|
|
17
|
+
// itself from the pinned root). The host is lower-cased: DNS names are case-insensitive.
|
|
18
|
+
export const remoteId = (remote: string | null): { host: string; slug: string } | null => {
|
|
19
|
+
if (remote === null) return null
|
|
20
|
+
const text = remote.trim()
|
|
21
|
+
const m = URL_REMOTE.exec(text) ?? (text.includes('://') ? null : SCP_REMOTE.exec(text))
|
|
22
|
+
return m === null ? null : { host: m[1].toLowerCase(), slug: `${m[2]}/${m[3]}` }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The repo as a pin: a working copy with a parseable remote. `toplevel` is the checkout's own root
|
|
26
|
+
// (a worktree's, where trailhead keeps its files); `repo.root` is the main working tree's.
|
|
27
|
+
export const pinOf = (repo: RepoRead | null, toplevel: string | null = null): RepoPin | null => {
|
|
28
|
+
const id = remoteId(repo?.remote ?? null)
|
|
29
|
+
return repo === null || id === null ? null : { root: toplevel ?? repo.root, ...id }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// What makes two pins the same repo (the root can differ between checkouts of it).
|
|
33
|
+
export const pinKey = (pin: RepoPin): string => `${pin.host}/${pin.slug}`
|
|
34
|
+
export const isSamePin = (a: RepoPin | null, b: RepoPin | null): boolean =>
|
|
35
|
+
a === null || b === null ? a === b : a.root === b.root && pinKey(a) === pinKey(b)
|
|
36
|
+
|
|
37
|
+
// Whether the checkout the session is in (what `session.repo()` and its toplevel read) is the pinned one.
|
|
38
|
+
export const isPinCheckout = (pin: RepoPin | null, repo: RepoRead | null, toplevel: string | null): boolean =>
|
|
39
|
+
pin !== null && toplevel !== null && isSamePin(pin, pinOf(repo, toplevel))
|
|
40
|
+
|
|
41
|
+
// A check outside any repo, or in one with no usable remote, keeps the last pin; another repo moves it.
|
|
42
|
+
export const nextPin = (current: RepoPin | null, repo: RepoRead | null, toplevel: string | null = null): RepoPin | null => {
|
|
43
|
+
const found = pinOf(repo, toplevel)
|
|
44
|
+
if (found === null) return current
|
|
45
|
+
return isSamePin(current, found) ? current : found
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The pinned checkout as the trailing word of a verb's args: `--in=` plus base64url of the root (alphabet
|
|
49
|
+
// [A-Za-z0-9_-], no padding), so a path with spaces, quotes or a leading `-` stays one shell-safe word. The verb
|
|
50
|
+
// decodes it with templates/trailhead-checkout-in.js and moves there first (#312).
|
|
51
|
+
// No Buffer in the mod's runtime: UTF-8 bytes through btoa, then the url-safe alphabet.
|
|
52
|
+
export const checkoutArg = (pin: RepoPin): string => {
|
|
53
|
+
const binary = Array.from(new TextEncoder().encode(pin.root), byte => String.fromCharCode(byte)).join('')
|
|
54
|
+
return `--in=${btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A repo-relative path under the pinned root, absolute whatever the session cwd is.
|
|
58
|
+
export const pinPath = (pin: RepoPin, relative: string): string => `${pin.root.replace(/\/+$/, '')}/${relative}`
|
|
59
|
+
|
|
60
|
+
// Same {plugin, key} as the copies the pane declares: the module scan wants the atom spelled in the file that uses it.
|
|
61
|
+
export const repoPin = atom({ plugin: 'trailhead', key: 'repoPin' } as const, null as RepoPin | null)
|
|
62
|
+
|
|
63
|
+
// Machinery, not drawn: survives a /clear, which empties the atom while the session stays in its repo.
|
|
64
|
+
let remembered: RepoPin | null = null
|
|
65
|
+
|
|
66
|
+
// The checkout's own root: `git rev-parse --show-toplevel` in the session's directory, the same one
|
|
67
|
+
// `$.session.repo()` reads, so root and remote always name one checkout. null when it cannot be read;
|
|
68
|
+
// the caller falls back.
|
|
69
|
+
export async function toplevelOf($: EngineInterface): Promise<string | null> {
|
|
70
|
+
try {
|
|
71
|
+
const out = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: await $.session.cwd(), timeoutMs: 5_000 })
|
|
72
|
+
const top = out.stdout.trim()
|
|
73
|
+
return out.exitCode === 0 && top.startsWith('/') ? top : null
|
|
74
|
+
} catch {
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The repo as git answers it, for when the host's `session.repo()` has nothing (#299): the two outputs
|
|
80
|
+
// of `git rev-parse --show-toplevel` and `git remote get-url origin`. null outside a repo; a repo with
|
|
81
|
+
// no origin has a null remote (no usable pin, so the last one stays).
|
|
82
|
+
type GitOut = { exitCode: number; stdout: string }
|
|
83
|
+
export const repoFromGit = (top: GitOut | null, origin: GitOut | null): RepoRead | null => {
|
|
84
|
+
const root = top !== null && top.exitCode === 0 ? top.stdout.trim() : ''
|
|
85
|
+
if (!root.startsWith('/')) return null
|
|
86
|
+
const remote = origin !== null && origin.exitCode === 0 ? origin.stdout.trim() : ''
|
|
87
|
+
return { root, remote: remote === '' ? null : remote }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The fallback read: null outside a repo (no second spawn then); `toplevel` is the root git just gave.
|
|
91
|
+
async function gitRepo($: EngineInterface): Promise<{ repo: RepoRead; toplevel: string } | null> {
|
|
92
|
+
try {
|
|
93
|
+
const cwd = await $.session.cwd()
|
|
94
|
+
const run = (argv: string[]) => $.process.run(argv, { cwd, timeoutMs: 5_000 })
|
|
95
|
+
const top = await run(['git', 'rev-parse', '--show-toplevel'])
|
|
96
|
+
if (repoFromGit(top, null) === null) return null
|
|
97
|
+
const repo = repoFromGit(top, await run(['git', 'remote', 'get-url', 'origin']))
|
|
98
|
+
return repo === null ? null : { repo, toplevel: repo.root }
|
|
99
|
+
} catch {
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The repo the session is in: the host's answer, else git's (#299). `toplevel` comes with it only on the
|
|
105
|
+
// git path, where it is already read; a throw of the host read reaches the caller. The git fallback covers
|
|
106
|
+
// a host whose `repo()` stays null for a repo created mid-session (anthropics/claude-code#104070): keep it
|
|
107
|
+
// while hosts without the upstream fix are supported, even once a fixed release ships.
|
|
108
|
+
export async function currentRepo($: EngineInterface): Promise<{ repo: RepoRead | null; toplevel: string | null }> {
|
|
109
|
+
const host = await $.session.repo()
|
|
110
|
+
if (host !== null) return { repo: host, toplevel: null }
|
|
111
|
+
return (await gitRepo($)) ?? { repo: null, toplevel: null }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// github.com is served by gh as it is; any other host only once `gh auth status --hostname <host>` succeeds
|
|
115
|
+
// (an Enterprise host the person logged in to). A success is kept for the life of the module (the
|
|
116
|
+
// session's start hooks of the pane and the band both refresh, so it is not reset there). A failure is
|
|
117
|
+
// kept only HOST_RETRY_MS: it may be a network blip or a login still to come, so it is asked again
|
|
118
|
+
// later. A check that could not run at all is not kept, so it is tried again.
|
|
119
|
+
const GITHUB_HOST = 'github.com'
|
|
120
|
+
const HOST_RETRY_MS = 60_000
|
|
121
|
+
// true: served; a number: the time the last check failed.
|
|
122
|
+
const servedHosts = new Map<string, true | number>()
|
|
123
|
+
|
|
124
|
+
async function isServed($: EngineInterface, host: string, cwd: string): Promise<boolean> {
|
|
125
|
+
if (host === GITHUB_HOST) return true
|
|
126
|
+
const known = servedHosts.get(host)
|
|
127
|
+
if (known === true) return true
|
|
128
|
+
const now = await $.clock.now()
|
|
129
|
+
if (known !== undefined && now - known < HOST_RETRY_MS) return false
|
|
130
|
+
try {
|
|
131
|
+
const out = await $.process.run(['gh', 'auth', 'status', '--hostname', host], { cwd, timeoutMs: 10_000 })
|
|
132
|
+
servedHosts.set(host, out.exitCode === 0 ? true : now)
|
|
133
|
+
return out.exitCode === 0
|
|
134
|
+
} catch {
|
|
135
|
+
return false
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Checks the working copy now and pins what it finds; a failed read keeps the last pin. The host's
|
|
140
|
+
// `session.repo()` can stay null for a repo created mid-session, so git in the cwd answers then (#299). A session
|
|
141
|
+
// start begins from nothing: the pin of an earlier session is not this one's.
|
|
142
|
+
export async function refreshPin($: EngineInterface, init: { isStart?: boolean; onMove?: (pin: RepoPin | null) => Promise<void> } = {}): Promise<RepoPin | null> {
|
|
143
|
+
const stored = await read($, repoPin)
|
|
144
|
+
const current = init.isStart === true ? null : (stored ?? remembered)
|
|
145
|
+
let repo: RepoRead | null = null
|
|
146
|
+
let toplevel: string | null = null
|
|
147
|
+
try {
|
|
148
|
+
const found = await currentRepo($)
|
|
149
|
+
repo = found.repo
|
|
150
|
+
const id = remoteId(repo?.remote ?? null)
|
|
151
|
+
if (repo !== null && id !== null) {
|
|
152
|
+
toplevel = found.toplevel ?? (await toplevelOf($))
|
|
153
|
+
// A host gh cannot serve is no usable remote: the last pin stays.
|
|
154
|
+
if (!(await isServed($, id.host, toplevel ?? repo.root))) repo = null
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
// Unreadable repo: keep the last pin.
|
|
158
|
+
}
|
|
159
|
+
const pin = nextPin(current, repo, toplevel)
|
|
160
|
+
remembered = pin
|
|
161
|
+
if (!sameValue(stored, pin)) {
|
|
162
|
+
// Right before the atom changes, whoever moves it, so what was read for the old pin goes no later than the change.
|
|
163
|
+
await init.onMove?.(pin)
|
|
164
|
+
await update($, repoPin, () => pin)
|
|
165
|
+
}
|
|
166
|
+
return pin
|
|
167
|
+
}
|