@excitedjs/tm 1.1.0 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -1
- package/src/cli/dispatch.ts +28 -4
- package/src/engines/claude/base-ref.ts +83 -0
- package/src/engines/claude/send.ts +52 -5
- package/src/engines/claude/spawn.ts +52 -2
- package/src/engines/claude/turn-jsonl.ts +165 -0
- package/src/engines/claude/wait-signals.ts +140 -1
- package/src/engines/types.ts +7 -1
- package/src/help.ts +60 -16
- package/src/persistence/identity-store.ts +46 -0
- package/src/verbs/format.ts +28 -0
- package/src/verbs/ls.ts +11 -4
- package/src/verbs/states.ts +11 -3
package/package.json
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@excitedjs/tm",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"description": "Multi-repo AI orchestrator CLI — spawn, message, and wait on Claude/Codex teammates across sibling repos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/excitedjs/claudemux.git",
|
|
10
|
+
"directory": "plugins/claudemux"
|
|
11
|
+
},
|
|
7
12
|
"engines": {
|
|
8
13
|
"node": ">=22.7.0"
|
|
9
14
|
},
|
package/src/cli/dispatch.ts
CHANGED
|
@@ -88,6 +88,24 @@ async function combineResults(results: readonly Promise<TmResult>[]): Promise<Tm
|
|
|
88
88
|
return { code, stdout, stderr }
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Parse the flag set `tm ls` / `tm states` accept. Both take only the
|
|
93
|
+
* optional `--all` (also list killed teammates from the kill-time
|
|
94
|
+
* identity archive); any positional or other flag is a usage error so
|
|
95
|
+
* a typo'd name does not silently behave like a bare fleet listing.
|
|
96
|
+
*/
|
|
97
|
+
function parseFleetListFlags(
|
|
98
|
+
verb: 'ls' | 'states',
|
|
99
|
+
rest: readonly string[],
|
|
100
|
+
): { all: boolean } | { error: TmResult } {
|
|
101
|
+
let all = false
|
|
102
|
+
for (const arg of rest) {
|
|
103
|
+
if (arg === '--all') all = true
|
|
104
|
+
else return { error: die(`tm ${verb}: unexpected argument '${arg}'. Usage: tm ${verb} [--all]`) }
|
|
105
|
+
}
|
|
106
|
+
return { all }
|
|
107
|
+
}
|
|
108
|
+
|
|
91
109
|
// ─── Engine-routed teammate verbs ─────────────────────────────────────────
|
|
92
110
|
|
|
93
111
|
const ENGINE_VERBS: ReadonlySet<string> = new Set([
|
|
@@ -114,10 +132,16 @@ async function dispatchEngineVerb(
|
|
|
114
132
|
env: NativeEnv,
|
|
115
133
|
): Promise<TmResult> {
|
|
116
134
|
switch (verb) {
|
|
117
|
-
case 'ls':
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
return
|
|
135
|
+
case 'ls': {
|
|
136
|
+
const parsed = parseFleetListFlags('ls', rest)
|
|
137
|
+
if ('error' in parsed) return parsed.error
|
|
138
|
+
return lsVerb(ctx, { all: parsed.all })
|
|
139
|
+
}
|
|
140
|
+
case 'states': {
|
|
141
|
+
const parsed = parseFleetListFlags('states', rest)
|
|
142
|
+
if ('error' in parsed) return parsed.error
|
|
143
|
+
return statesVerb(ctx, { all: parsed.all })
|
|
144
|
+
}
|
|
121
145
|
case 'status': {
|
|
122
146
|
if (rest.length === 0) {
|
|
123
147
|
return { code: 1, stdout: '', stderr: 'tm: usage: tm status <name> [lines=80]\n' }
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tm spawn`'s base-ref note. A fresh worktree teammate branches from
|
|
3
|
+
* the source repo's current HEAD (`claude --worktree` pins
|
|
4
|
+
* `worktree.baseRef = "head"`). When the repo happens to be parked on a
|
|
5
|
+
* branch other than the trunk, the teammate silently starts from the
|
|
6
|
+
* wrong baseline — so spawn prints a one-line summary of that ref:
|
|
7
|
+
* the current branch + short sha, plus a best-effort ahead/behind
|
|
8
|
+
* against the remote default branch.
|
|
9
|
+
*
|
|
10
|
+
* Everything here is best-effort and read-only: a repo that is not a
|
|
11
|
+
* git checkout, a missing `git`, or any failing probe yields `null`,
|
|
12
|
+
* and the caller simply omits the line. The base-ref note must never
|
|
13
|
+
* throw and must never fail a spawn — surfacing the baseline is a
|
|
14
|
+
* convenience, not a gate.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawnCapture } from '../../proc'
|
|
18
|
+
|
|
19
|
+
/** The `git` shell-out the note runs through; injectable so unit tests stay hermetic. */
|
|
20
|
+
export type GitRunner = (
|
|
21
|
+
args: readonly string[],
|
|
22
|
+
) => Promise<{ code: number; stdout: string; stderr: string }>
|
|
23
|
+
|
|
24
|
+
const defaultRunner: GitRunner = (args) => spawnCapture(args)
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Ahead/behind of HEAD relative to the repo's remote default branch
|
|
28
|
+
* (`origin/HEAD`, e.g. `origin/main`). Returns `null` when no trunk
|
|
29
|
+
* resolves — `origin/HEAD` is frequently unset on a local clone — so
|
|
30
|
+
* the caller drops the comparison rather than guessing a trunk.
|
|
31
|
+
*/
|
|
32
|
+
async function aheadBehind(repo: string, run: GitRunner): Promise<string | null> {
|
|
33
|
+
try {
|
|
34
|
+
const trunkRes = await run(['git', '-C', repo, 'rev-parse', '--abbrev-ref', 'origin/HEAD'])
|
|
35
|
+
if (trunkRes.code !== 0) return null
|
|
36
|
+
const trunk = trunkRes.stdout.trim()
|
|
37
|
+
if (trunk.length === 0) return null
|
|
38
|
+
const countRes = await run([
|
|
39
|
+
'git', '-C', repo, 'rev-list', '--left-right', '--count', `HEAD...${trunk}`,
|
|
40
|
+
])
|
|
41
|
+
if (countRes.code !== 0) return null
|
|
42
|
+
const parts = countRes.stdout.trim().split(/\s+/)
|
|
43
|
+
const ahead = Number(parts[0])
|
|
44
|
+
const behind = Number(parts[1])
|
|
45
|
+
if (!Number.isFinite(ahead) || !Number.isFinite(behind)) return null
|
|
46
|
+
if (ahead === 0 && behind === 0) return `in sync with ${trunk}`
|
|
47
|
+
const bits: string[] = []
|
|
48
|
+
if (ahead > 0) bits.push(`${ahead} ahead`)
|
|
49
|
+
if (behind > 0) bits.push(`${behind} behind`)
|
|
50
|
+
return `${bits.join(' / ')} ${trunk}`
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A one-line summary of the ref a fresh spawn branches from, e.g.
|
|
58
|
+
* `main (a1b2c3d), in sync with origin/main` or
|
|
59
|
+
* `feat/x (a1b2c3d), 2 ahead / 5 behind origin/main`, or
|
|
60
|
+
* `detached @ a1b2c3d` for a detached HEAD. Returns `null` when the
|
|
61
|
+
* repo is not a git checkout or any required probe fails.
|
|
62
|
+
*/
|
|
63
|
+
export async function gitBaseRefNote(
|
|
64
|
+
repo: string,
|
|
65
|
+
run: GitRunner = defaultRunner,
|
|
66
|
+
): Promise<string | null> {
|
|
67
|
+
let branch: string
|
|
68
|
+
let sha: string
|
|
69
|
+
try {
|
|
70
|
+
const branchRes = await run(['git', '-C', repo, 'rev-parse', '--abbrev-ref', 'HEAD'])
|
|
71
|
+
if (branchRes.code !== 0) return null
|
|
72
|
+
branch = branchRes.stdout.trim()
|
|
73
|
+
const shaRes = await run(['git', '-C', repo, 'rev-parse', '--short', 'HEAD'])
|
|
74
|
+
if (shaRes.code !== 0) return null
|
|
75
|
+
sha = shaRes.stdout.trim()
|
|
76
|
+
} catch {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
if (branch.length === 0 || sha.length === 0) return null
|
|
80
|
+
const head = branch === 'HEAD' ? `detached @ ${sha}` : `${branch} (${sha})`
|
|
81
|
+
const divergence = await aheadBehind(repo, run)
|
|
82
|
+
return divergence === null ? head : `${head}, ${divergence}`
|
|
83
|
+
}
|
|
@@ -13,9 +13,15 @@
|
|
|
13
13
|
* (which fans out by calling `claudeSend` directly).
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { writeFileSync } from 'node:fs'
|
|
17
|
+
|
|
16
18
|
import { sendKeys } from './keys'
|
|
17
|
-
import { probeStillAlive,
|
|
19
|
+
import { confirmSubmit, probeStillAlive, waitForTurnEnd, waitPaneQuiet } from './wait-signals'
|
|
18
20
|
import { echoCtxToStderr, printLastOrEmpty } from './post-turn'
|
|
21
|
+
import { transcriptFile } from './ctx'
|
|
22
|
+
import { lastAssistantTextAfter, transcriptSizeBytes } from './turn-jsonl'
|
|
23
|
+
import { readIfNonEmpty, resolveSid, rstrip } from './idle'
|
|
24
|
+
import { cwdFile, lastFileFor } from '../../persistence/paths'
|
|
19
25
|
import { die } from './tmux'
|
|
20
26
|
import { isNonNegativeInteger } from './clock'
|
|
21
27
|
import { parseSendArgs } from '../../shared/verb-args'
|
|
@@ -46,14 +52,35 @@ export async function claudeSend(args: readonly string[], env: ClaudeVerbEnv): P
|
|
|
46
52
|
return die(`tm send: --timeout must be a non-negative integer (got: '${timeout}')`)
|
|
47
53
|
}
|
|
48
54
|
|
|
55
|
+
// Snapshot the transcript offset BEFORE sending so submit-confirmation
|
|
56
|
+
// and the JSONL wait fallback only read what THIS turn appends — never
|
|
57
|
+
// a prior turn's settled entry. `null` when the transcript path cannot
|
|
58
|
+
// be resolved (no recorded cwd/sid); the JSONL-side checks then no-op
|
|
59
|
+
// and the marker-based behavior stands alone.
|
|
60
|
+
const sid0 = resolveSid(name)
|
|
61
|
+
const cwdRaw = readIfNonEmpty(cwdFile(name))
|
|
62
|
+
const cwd0 = cwdRaw === null ? null : rstrip(cwdRaw)
|
|
63
|
+
const jsonl = sid0 !== null && cwd0 !== null ? transcriptFile(env.projectsDir, cwd0, sid0) : null
|
|
64
|
+
const anchor = { jsonl, sinceBytes: jsonl !== null ? transcriptSizeBytes(jsonl) : 0 }
|
|
65
|
+
|
|
49
66
|
const sentResult = await sendKeys(name, prompt, env.runTmux, process.env)
|
|
50
67
|
if (sentResult.code !== 0) return sentResult
|
|
51
68
|
|
|
69
|
+
// Confirm the prompt was accepted as a turn (not swallowed by a modal).
|
|
70
|
+
// Warn-and-proceed only — never converts a slow-but-live send into a
|
|
71
|
+
// failure; the wait below still expires to 124 if the turn never runs.
|
|
72
|
+
// Pane-quiet covers TUI commands with no turn to confirm, so skip it.
|
|
73
|
+
let confirmStderr = ''
|
|
74
|
+
if (!paneQuiet) {
|
|
75
|
+
const confirmed = await confirmSubmit(name, anchor, env.runTmux)
|
|
76
|
+
if (!confirmed.ok) confirmStderr = confirmed.warn
|
|
77
|
+
}
|
|
78
|
+
|
|
52
79
|
const timeoutSec = timeout === null ? 1800 : Number(timeout)
|
|
53
80
|
const verdict = paneQuiet
|
|
54
81
|
? await waitPaneQuiet(name, timeoutSec, env.runTmux)
|
|
55
|
-
: await
|
|
56
|
-
if ('code' in verdict) return verdict
|
|
82
|
+
: await waitForTurnEnd(name, timeoutSec, false, env.runTmux, anchor)
|
|
83
|
+
if ('code' in verdict) return { ...verdict, stderr: confirmStderr + verdict.stderr }
|
|
57
84
|
if (!verdict.ok) {
|
|
58
85
|
// Re-probe at the timeout moment: a teammate that died mid-wait must
|
|
59
86
|
// NOT be reported as "still running" with code 124, or the dispatcher's
|
|
@@ -62,7 +89,7 @@ export async function claudeSend(args: readonly string[], env: ClaudeVerbEnv): P
|
|
|
62
89
|
// promise 124 ("still running") when the session + sid are still there.
|
|
63
90
|
const dead = await probeStillAlive(name, env.runTmux)
|
|
64
91
|
if (dead !== null) {
|
|
65
|
-
return { ...dead, stderr: sentResult.stderr + dead.stderr }
|
|
92
|
+
return { ...dead, stderr: sentResult.stderr + confirmStderr + dead.stderr }
|
|
66
93
|
}
|
|
67
94
|
const kind = paneQuiet ? 'pane-quiet' : 'Stop hook'
|
|
68
95
|
return {
|
|
@@ -70,17 +97,37 @@ export async function claudeSend(args: readonly string[], env: ClaudeVerbEnv): P
|
|
|
70
97
|
stdout: printLastOrEmpty(name),
|
|
71
98
|
stderr:
|
|
72
99
|
sentResult.stderr +
|
|
100
|
+
confirmStderr +
|
|
73
101
|
`tm send: sync wait expired after ${timeoutSec}s on ${name} ` +
|
|
74
102
|
`(no ${kind} fired; the teammate is still running — tail with ` +
|
|
75
103
|
`'tm wait ${name}' or check 'tm status ${name}'). exit ${EXIT_SYNC_WAIT_EXPIRED}.\n`,
|
|
76
104
|
}
|
|
77
105
|
}
|
|
78
106
|
|
|
107
|
+
// No-hook JSONL fallback: the turn settled in the transcript but the
|
|
108
|
+
// Stop hook never wrote `<sid>.last` (sendKeys' clearIdle wiped it at
|
|
109
|
+
// send time and no hook repopulated it). Recover the reply from THIS
|
|
110
|
+
// turn's appended region — scoped to the send offset, so a prior turn's
|
|
111
|
+
// text is never surfaced — and persist it exactly as `tm spawn --resume`
|
|
112
|
+
// seeds `.last`, so stdout AND `tm last` / `tm states` all surface the
|
|
113
|
+
// reply instead of the "(no text reply...)" sentinel. A textless turn
|
|
114
|
+
// (tool-only) writes an empty `.last`, clearing any stale value. The
|
|
115
|
+
// marker path is left untouched: on-stop writes `.last` before touching
|
|
116
|
+
// the idle marker, so `via: 'marker'` means `.last` is already current.
|
|
117
|
+
if (!paneQuiet && 'via' in verdict && verdict.via === 'jsonl' && jsonl !== null && sid0 !== null) {
|
|
118
|
+
const recovered = lastAssistantTextAfter(jsonl, anchor.sinceBytes)
|
|
119
|
+
try {
|
|
120
|
+
writeFileSync(lastFileFor(sid0), recovered !== null && recovered.length > 0 ? `${recovered}\n` : '')
|
|
121
|
+
} catch {
|
|
122
|
+
// Best-effort: printLastOrEmpty falls back to the sentinel if unwritten.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
79
126
|
let trailingStderr = ''
|
|
80
127
|
if (!paneQuiet) trailingStderr = echoCtxToStderr(name, env)
|
|
81
128
|
return {
|
|
82
129
|
code: 0,
|
|
83
130
|
stdout: printLastOrEmpty(name),
|
|
84
|
-
stderr: sentResult.stderr + trailingStderr,
|
|
131
|
+
stderr: sentResult.stderr + confirmStderr + trailingStderr,
|
|
85
132
|
}
|
|
86
133
|
}
|
|
@@ -29,6 +29,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
|
29
29
|
import { dirname } from 'node:path'
|
|
30
30
|
|
|
31
31
|
import { claudeSend } from './send'
|
|
32
|
+
import { gitBaseRefNote } from './base-ref'
|
|
32
33
|
import { readLastAssistantText, transcriptFile } from './ctx'
|
|
33
34
|
import { clearIdle, isDirectory } from './idle'
|
|
34
35
|
import { newSid } from './identifiers'
|
|
@@ -47,6 +48,25 @@ import {
|
|
|
47
48
|
import type { ClaudeVerbEnv } from './env'
|
|
48
49
|
import { EXIT_SYNC_WAIT_EXPIRED, type TmResult } from '../../tm'
|
|
49
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Value for `CLAUDE_CODE_RESUME_TOKEN_THRESHOLD`, injected into every
|
|
53
|
+
* teammate tmux session. Claude Code raises a "Resume from summary
|
|
54
|
+
* (recommended) / Resume full session as-is" startup prompt when a
|
|
55
|
+
* resumed session is older than `CLAUDE_CODE_RESUME_THRESHOLD_MINUTES`
|
|
56
|
+
* (default 70) AND larger than `CLAUDE_CODE_RESUME_TOKEN_THRESHOLD`
|
|
57
|
+
* (default 100_000) tokens. A teammate has no human to answer that
|
|
58
|
+
* modal, and the next `tm send`'s Enter lands on it — picking the
|
|
59
|
+
* default "summary" option, which runs `/compact` and discards the
|
|
60
|
+
* full context the resume was meant to restore. Setting the token
|
|
61
|
+
* threshold far above any real context window keeps that condition
|
|
62
|
+
* permanently false, so the prompt never renders and a resumed
|
|
63
|
+
* teammate always loads its full session silently. Degrades safely:
|
|
64
|
+
* a Claude Code build that drops this knob simply re-exposes the
|
|
65
|
+
* prompt, falling back to the documented "confirm with tm status"
|
|
66
|
+
* guidance.
|
|
67
|
+
*/
|
|
68
|
+
const RESUME_TOKEN_THRESHOLD_SUPPRESS = 100_000_000
|
|
69
|
+
|
|
50
70
|
interface ClaudeLaunchArgs {
|
|
51
71
|
/** Physical repo path (the parent of the worktree, or the cwd itself). */
|
|
52
72
|
readonly repo: string
|
|
@@ -84,9 +104,15 @@ function shellSingleQuote(value: string): string {
|
|
|
84
104
|
* `claude --session-id|--resume <sid>` and the optional `-n '<name>'`
|
|
85
105
|
* / `--worktree <slug>` extras. A bare tool name in
|
|
86
106
|
* `--disallowedTools` drops it from the model's context entirely.
|
|
107
|
+
*
|
|
108
|
+
* `EnterPlanMode` / `ExitPlanMode` join `AskUserQuestion` on the
|
|
109
|
+
* disallow list for the same reason: plan mode opens an approval modal
|
|
110
|
+
* that holds the turn open waiting for a human, and a teammate has no
|
|
111
|
+
* human at its terminal. A teammate proposes a plan by ending its turn
|
|
112
|
+
* with text, which `tm send` relays to the dispatcher.
|
|
87
113
|
*/
|
|
88
114
|
function teammateLaunchFlags(mdExcludes: string): string {
|
|
89
|
-
return `--settings ${shellSingleQuote(mdExcludes)} --disallowedTools AskUserQuestion`
|
|
115
|
+
return `--settings ${shellSingleQuote(mdExcludes)} --disallowedTools AskUserQuestion,EnterPlanMode,ExitPlanMode`
|
|
90
116
|
}
|
|
91
117
|
|
|
92
118
|
/**
|
|
@@ -318,7 +344,12 @@ async function claudeLaunch(
|
|
|
318
344
|
// cannot wrong-route. `-e CLAUDEMUX_TEAMMATE_NAME=...` is the
|
|
319
345
|
// positive identity gate the on-session-start hook reads to
|
|
320
346
|
// discriminate "this teammate" from "the dispatcher happens to
|
|
321
|
-
// share the cwd".
|
|
347
|
+
// share the cwd". `-e CLAUDE_CODE_RESUME_TOKEN_THRESHOLD=...`
|
|
348
|
+
// suppresses Claude Code's "resume from summary vs full session"
|
|
349
|
+
// startup prompt (see RESUME_TOKEN_THRESHOLD_SUPPRESS): a teammate
|
|
350
|
+
// is headless and cannot answer that modal, and the prompt's
|
|
351
|
+
// Enter-default picks the compacted summary — silently dropping the
|
|
352
|
+
// very context a resume is meant to restore.
|
|
322
353
|
let paneId = ''
|
|
323
354
|
try {
|
|
324
355
|
const newSession = await env.runTmux([
|
|
@@ -330,6 +361,8 @@ async function claudeLaunch(
|
|
|
330
361
|
paneCwd,
|
|
331
362
|
'-e',
|
|
332
363
|
`CLAUDEMUX_TEAMMATE_NAME=${name}`,
|
|
364
|
+
'-e',
|
|
365
|
+
`CLAUDE_CODE_RESUME_TOKEN_THRESHOLD=${RESUME_TOKEN_THRESHOLD_SUPPRESS}`,
|
|
333
366
|
'-P',
|
|
334
367
|
'-F',
|
|
335
368
|
'#{session_id}',
|
|
@@ -367,6 +400,23 @@ async function claudeLaunch(
|
|
|
367
400
|
stderr += `spawned: ${name} (tmux=${session}, cwd=${cwd}${worktreeNote}, sid=${sid})\n`
|
|
368
401
|
}
|
|
369
402
|
|
|
403
|
+
// Surface the baseline a fresh spawn starts from. A worktree teammate
|
|
404
|
+
// branches `worktree-<slug>` off the repo's current HEAD; a
|
|
405
|
+
// `--no-worktree` teammate runs on it directly. Printing the branch +
|
|
406
|
+
// short sha (plus best-effort ahead/behind vs the remote default)
|
|
407
|
+
// makes a repo parked on the wrong branch obvious instead of silent.
|
|
408
|
+
// Only on the genuine fresh-launch path — resume / continue reuse an
|
|
409
|
+
// existing session, and an already-present worktree is not re-created
|
|
410
|
+
// from HEAD, so the note would mislead in those cases.
|
|
411
|
+
if (resumeSid.length === 0 && !continueLatest) {
|
|
412
|
+
const worktreeAlreadyThere =
|
|
413
|
+
worktreeSlug !== null && existsSync(worktreePathFor(repo, worktreeSlug))
|
|
414
|
+
if (!worktreeAlreadyThere) {
|
|
415
|
+
const baseNote = await gitBaseRefNote(repo)
|
|
416
|
+
if (baseNote !== null) stderr += `base: ${baseNote}\n`
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
370
420
|
if (!continueLatest) {
|
|
371
421
|
const sf = sidFile(name)
|
|
372
422
|
mkdirSync(dirname(sf), { recursive: true })
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSONL turn-state reads for the Claude engine — the transcript-side
|
|
3
|
+
* signals that back `tm send`'s submit confirmation and the no-hook wait
|
|
4
|
+
* fallback. These mirror what `hooks/on-stop.sh` decides in bash
|
|
5
|
+
* (terminal stop_reason + a text/tool_use block = a settled turn), but
|
|
6
|
+
* run inside `tm` so a session whose Stop hook never fired still gets
|
|
7
|
+
* JSONL-grade turn detection instead of a pane-quiet heuristic.
|
|
8
|
+
*
|
|
9
|
+
* Every read is anchored to a byte offset snapshotted at send time, so a
|
|
10
|
+
* settled assistant entry from a PRIOR turn (which lives before the
|
|
11
|
+
* offset) can never be mistaken for this turn's completion. Reads of the
|
|
12
|
+
* appended region only; a file that shrank (compaction rewrote it) is
|
|
13
|
+
* treated as "nothing new yet" rather than risking a false positive.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { closeSync, openSync, readSync, statSync } from 'node:fs'
|
|
17
|
+
|
|
18
|
+
/** Terminal `stop_reason` values — the API call ended without expecting the agent loop to continue. */
|
|
19
|
+
const TERMINAL_STOP_REASONS = new Set(['end_turn', 'stop_sequence', 'max_tokens', 'refusal'])
|
|
20
|
+
|
|
21
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
22
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Byte size of the transcript, or 0 when it does not exist yet. */
|
|
26
|
+
export function transcriptSizeBytes(path: string): number {
|
|
27
|
+
try {
|
|
28
|
+
return statSync(path).size
|
|
29
|
+
} catch {
|
|
30
|
+
return 0
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read the bytes appended after `sinceBytes`. Returns `''` when the file
|
|
36
|
+
* is missing, unreadable, or no larger than `sinceBytes` (including the
|
|
37
|
+
* shrink-after-compaction case). Reading only the tail keeps every poll
|
|
38
|
+
* O(one turn) rather than O(whole transcript).
|
|
39
|
+
*/
|
|
40
|
+
function readAppended(path: string, sinceBytes: number): string {
|
|
41
|
+
let fd: number
|
|
42
|
+
try {
|
|
43
|
+
const size = statSync(path).size
|
|
44
|
+
if (size <= sinceBytes) return ''
|
|
45
|
+
fd = openSync(path, 'r')
|
|
46
|
+
try {
|
|
47
|
+
const len = size - sinceBytes
|
|
48
|
+
const buf = Buffer.allocUnsafe(len)
|
|
49
|
+
const read = readSync(fd, buf, 0, len, sinceBytes)
|
|
50
|
+
return buf.toString('utf8', 0, read)
|
|
51
|
+
} finally {
|
|
52
|
+
closeSync(fd)
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
return ''
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Parse the complete JSONL lines in `text`; a trailing partial line (mid-write) is dropped. */
|
|
60
|
+
function parsedLines(text: string): Record<string, unknown>[] {
|
|
61
|
+
const out: Record<string, unknown>[] = []
|
|
62
|
+
for (const line of text.split('\n')) {
|
|
63
|
+
const trimmed = line.trim()
|
|
64
|
+
if (trimmed === '') continue
|
|
65
|
+
let value: unknown
|
|
66
|
+
try {
|
|
67
|
+
value = JSON.parse(trimmed)
|
|
68
|
+
} catch {
|
|
69
|
+
// A half-written final line, or a non-JSON line — skip it. A real
|
|
70
|
+
// entry reappears intact on the next poll.
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
if (isPlainObject(value)) out.push(value)
|
|
74
|
+
}
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether any `type: "user"` entry appears in the region appended after
|
|
80
|
+
* `sinceBytes`. Claude Code writes the submitted prompt as a user entry,
|
|
81
|
+
* so a new user entry after the send offset is positive evidence the
|
|
82
|
+
* input was accepted as a turn — the signal `tm send` uses to tell
|
|
83
|
+
* "submitted" from "the Enter was swallowed by a modal". Slash-command
|
|
84
|
+
* and tool_result entries are also `type: "user"`; counting them is
|
|
85
|
+
* correct here — any of them proves the REPL took the input, which is
|
|
86
|
+
* exactly the question submit-confirmation answers.
|
|
87
|
+
*/
|
|
88
|
+
export function userEntryAppearedAfter(path: string, sinceBytes: number): boolean {
|
|
89
|
+
for (const entry of parsedLines(readAppended(path, sinceBytes))) {
|
|
90
|
+
if (entry['type'] === 'user') return true
|
|
91
|
+
}
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether the most recent assistant entry in the region appended after
|
|
97
|
+
* `sinceBytes` is SETTLED — terminal `stop_reason` AND at least one
|
|
98
|
+
* `text` or `tool_use` content block. This is `on-stop.sh`'s
|
|
99
|
+
* `is_assistant_settled` predicate, scoped to this turn's appended
|
|
100
|
+
* region:
|
|
101
|
+
*
|
|
102
|
+
* - mid tool-loop → the last assistant entry's stop_reason is
|
|
103
|
+
* `tool_use` (non-terminal) → not settled → keep waiting;
|
|
104
|
+
* - a thinking-only response that ended with `end_turn` before the
|
|
105
|
+
* text response landed → no text/tool_use block → not settled →
|
|
106
|
+
* keep waiting (the split-turn case `on-stop.sh` guards against);
|
|
107
|
+
* - the final text/tool_use response with a terminal stop_reason →
|
|
108
|
+
* settled → the turn is over.
|
|
109
|
+
*/
|
|
110
|
+
export function terminalAssistantAfter(path: string, sinceBytes: number): boolean {
|
|
111
|
+
let lastAssistantSettled = false
|
|
112
|
+
let sawAssistant = false
|
|
113
|
+
for (const entry of parsedLines(readAppended(path, sinceBytes))) {
|
|
114
|
+
if (entry['type'] !== 'assistant') continue
|
|
115
|
+
sawAssistant = true
|
|
116
|
+
const message = entry['message']
|
|
117
|
+
if (!isPlainObject(message)) {
|
|
118
|
+
lastAssistantSettled = false
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
const reason = message['stop_reason']
|
|
122
|
+
const content = Array.isArray(message['content']) ? message['content'] : []
|
|
123
|
+
const types = new Set(
|
|
124
|
+
content.filter(isPlainObject).map((block) => block['type']),
|
|
125
|
+
)
|
|
126
|
+
lastAssistantSettled =
|
|
127
|
+
typeof reason === 'string' &&
|
|
128
|
+
TERMINAL_STOP_REASONS.has(reason) &&
|
|
129
|
+
(types.has('text') || types.has('tool_use'))
|
|
130
|
+
}
|
|
131
|
+
return sawAssistant && lastAssistantSettled
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The joined text of the most recent text-bearing assistant entry in the
|
|
136
|
+
* region appended after `sinceBytes`, or `null` when none exists (no
|
|
137
|
+
* assistant entry, or a tool-only / thinking-only turn with no `text`
|
|
138
|
+
* block). Mirrors `readLastAssistantText` — the on-stop hook's
|
|
139
|
+
* `extract_last_turn` shape — but scoped to THIS turn's appended region,
|
|
140
|
+
* so a prior turn's reply can never be recovered as this turn's.
|
|
141
|
+
*
|
|
142
|
+
* `tm send` uses this on the no-hook JSONL wait-fallback path: the Stop
|
|
143
|
+
* hook never wrote `<sid>.last`, so the deliverable lives only in the
|
|
144
|
+
* transcript, and this recovers it to repopulate `.last` and stdout.
|
|
145
|
+
*/
|
|
146
|
+
export function lastAssistantTextAfter(path: string, sinceBytes: number): string | null {
|
|
147
|
+
const entries = parsedLines(readAppended(path, sinceBytes))
|
|
148
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
149
|
+
const entry = entries[i]!
|
|
150
|
+
if (entry['type'] !== 'assistant') continue
|
|
151
|
+
const message = entry['message']
|
|
152
|
+
if (!isPlainObject(message)) continue
|
|
153
|
+
const content = Array.isArray(message['content']) ? message['content'] : []
|
|
154
|
+
const texts: string[] = []
|
|
155
|
+
for (const block of content) {
|
|
156
|
+
if (!isPlainObject(block)) continue
|
|
157
|
+
if (block['type'] !== 'text') continue
|
|
158
|
+
const t = block['text']
|
|
159
|
+
if (typeof t === 'string') texts.push(t)
|
|
160
|
+
}
|
|
161
|
+
const joined = texts.join('')
|
|
162
|
+
if (joined.length > 0) return joined
|
|
163
|
+
}
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
@@ -9,12 +9,151 @@ import { existsSync, statSync } from 'node:fs'
|
|
|
9
9
|
|
|
10
10
|
import { clearIdle, resolveSidOrDie, resolveSid, isRegularFile } from './idle'
|
|
11
11
|
import { busyMarkerFor, idleMarkerFor, sendAtFile } from '../../persistence/paths'
|
|
12
|
-
import { requireSession } from './tmux'
|
|
12
|
+
import { requireSession, resolvePaneTarget } from './tmux'
|
|
13
13
|
import { nowSec, sleepMs } from './clock'
|
|
14
|
+
import { terminalAssistantAfter, userEntryAppearedAfter } from './turn-jsonl'
|
|
14
15
|
import type { TeammateName } from '../types'
|
|
15
16
|
import type { TmResult } from '../../tm'
|
|
16
17
|
import type { TmuxRunner } from '../../tmux'
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* A turn's JSONL anchor — the transcript path and the byte offset
|
|
21
|
+
* snapshotted at send time. `tm send` passes this so the submit
|
|
22
|
+
* confirmation and the wait can read only the region appended by THIS
|
|
23
|
+
* turn (never a prior turn's settled entry). `jsonl` is `null` when the
|
|
24
|
+
* transcript path could not be resolved (no recorded cwd / sid), in
|
|
25
|
+
* which case the JSONL-side checks are skipped and the marker-based
|
|
26
|
+
* behavior stands alone.
|
|
27
|
+
*/
|
|
28
|
+
export interface TurnAnchor {
|
|
29
|
+
readonly jsonl: string | null
|
|
30
|
+
readonly sinceBytes: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Total budget (ms) for `confirmSubmit`. Override via
|
|
35
|
+
* `CLAUDEMUX_CONFIRM_SUBMIT_MS`; `0` disables confirmation entirely
|
|
36
|
+
* (the conformance harness and the sync-wait tests set it so a synthetic
|
|
37
|
+
* "send succeeded, wait expires" scenario is not held up — or reshaped —
|
|
38
|
+
* by submit confirmation). Default 4s: a real submission's turn-start
|
|
39
|
+
* signal (the on-busy marker, or the prompt's user entry in the jsonl)
|
|
40
|
+
* lands well under a second, so the budget is a ceiling for the
|
|
41
|
+
* not-submitted case, not a cost every send pays.
|
|
42
|
+
*/
|
|
43
|
+
function confirmSubmitBudgetMs(): number {
|
|
44
|
+
const raw = process.env['CLAUDEMUX_CONFIRM_SUBMIT_MS']
|
|
45
|
+
if (raw !== undefined && raw !== '') {
|
|
46
|
+
const parsed = Number(raw)
|
|
47
|
+
if (Number.isFinite(parsed) && parsed >= 0) return parsed
|
|
48
|
+
}
|
|
49
|
+
return 4000
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* After `tm send` injects a prompt + Enter, verify the REPL actually
|
|
54
|
+
* accepted it as a turn — rather than the Enter being swallowed by a
|
|
55
|
+
* modal (the bug #2 class) and the prompt text discarded. "Accepted" is
|
|
56
|
+
* any of: the on-busy hook set the `.busy` marker, the idle marker
|
|
57
|
+
* reappeared (a fast turn already ended), or a new user entry landed in
|
|
58
|
+
* the transcript past the send offset (hook-independent). When none of
|
|
59
|
+
* those appears within an attempt's slice, re-send Enter (the common
|
|
60
|
+
* "the first Enter did not register" case) and recheck, up to 3
|
|
61
|
+
* attempts. Returns `{ ok: true }` on confirmation; otherwise a warning
|
|
62
|
+
* the caller prepends to stderr — `tm send` then PROCEEDS to the wait
|
|
63
|
+
* (which expires to 124 if the turn truly never runs), so confirmation
|
|
64
|
+
* never converts a working-but-slow send into a hard failure.
|
|
65
|
+
*/
|
|
66
|
+
export async function confirmSubmit(
|
|
67
|
+
name: TeammateName,
|
|
68
|
+
anchor: TurnAnchor,
|
|
69
|
+
runTmux: TmuxRunner,
|
|
70
|
+
): Promise<{ ok: true } | { ok: false; warn: string }> {
|
|
71
|
+
const totalMs = confirmSubmitBudgetMs()
|
|
72
|
+
if (totalMs <= 0) return { ok: true }
|
|
73
|
+
|
|
74
|
+
const submitted = (): boolean => {
|
|
75
|
+
const sid = resolveSid(name)
|
|
76
|
+
if (sid !== null && (isRegularFile(busyMarkerFor(sid)) || existsSync(idleMarkerFor(sid)))) {
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
return anchor.jsonl !== null && userEntryAppearedAfter(anchor.jsonl, anchor.sinceBytes)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const attempts = 3
|
|
83
|
+
const tickMs = 200
|
|
84
|
+
const ticksPerAttempt = Math.max(1, Math.round(totalMs / attempts / tickMs))
|
|
85
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
86
|
+
for (let tick = 0; tick < ticksPerAttempt; tick++) {
|
|
87
|
+
if (submitted()) return { ok: true }
|
|
88
|
+
await sleepMs(tickMs)
|
|
89
|
+
}
|
|
90
|
+
if (attempt < attempts) {
|
|
91
|
+
// Re-send Enter best-effort. Targets the resolved pane so a
|
|
92
|
+
// prefix-match cannot wrong-route; a failure here is swallowed —
|
|
93
|
+
// the next attempt's poll (or the final warning) covers it.
|
|
94
|
+
try {
|
|
95
|
+
const pane = await resolvePaneTarget(name, runTmux)
|
|
96
|
+
if (pane !== '') await runTmux(['send-keys', '-t', pane, 'Enter'])
|
|
97
|
+
} catch {
|
|
98
|
+
// ignore — best-effort retry.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (submitted()) return { ok: true }
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
warn:
|
|
106
|
+
`tm send: ${name}: no turn-start signal after re-sending Enter ` +
|
|
107
|
+
`${attempts - 1}x in ~${Math.round(totalMs / 1000)}s — the prompt may not have ` +
|
|
108
|
+
`landed (the REPL may be at a modal, or the turn is very slow to start). ` +
|
|
109
|
+
`Proceeding to wait; if no reply arrives, check 'tm status ${name}'.\n`,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Which signal ended the turn — the Stop-hook idle marker, or the transcript. */
|
|
114
|
+
export type TurnEndSignal = 'marker' | 'jsonl'
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Block until the teammate's turn ends, by either signal: the Stop-hook
|
|
118
|
+
* idle marker `/tmp/claude-idle/<sid>` (the primary path), OR — when the
|
|
119
|
+
* transcript path resolved — a settled assistant entry appended past the
|
|
120
|
+
* send offset (`terminalAssistantAfter`). The JSONL branch is the
|
|
121
|
+
* no-hook fallback: a session whose Stop hook never fired still unblocks
|
|
122
|
+
* here once its turn reaches a terminal stop_reason on disk, instead of
|
|
123
|
+
* burning the full timeout to a 124. When `jsonl` is `null` the JSONL
|
|
124
|
+
* branch never trips, so this is byte-for-byte `waitIdleSignal`.
|
|
125
|
+
*
|
|
126
|
+
* On success it reports WHICH signal ended the turn (`via`). The caller
|
|
127
|
+
* needs this: on `via: 'marker'` the Stop hook already wrote `<sid>.last`
|
|
128
|
+
* (it writes `.last` before touching the idle marker), but on
|
|
129
|
+
* `via: 'jsonl'` no hook ran, so `.last` is absent and the reply must be
|
|
130
|
+
* recovered from the transcript. This function itself stays read-only.
|
|
131
|
+
*/
|
|
132
|
+
export async function waitForTurnEnd(
|
|
133
|
+
name: TeammateName,
|
|
134
|
+
timeoutSec: number,
|
|
135
|
+
fresh: boolean,
|
|
136
|
+
runTmux: TmuxRunner,
|
|
137
|
+
anchor: TurnAnchor,
|
|
138
|
+
): Promise<TmResult | { ok: true; via: TurnEndSignal } | { ok: false }> {
|
|
139
|
+
const sessionMissing = await requireSession(name, runTmux)
|
|
140
|
+
if (sessionMissing !== null) return sessionMissing
|
|
141
|
+
const sidR = resolveSidOrDie(name)
|
|
142
|
+
if ('error' in sidR) return sidR.error
|
|
143
|
+
if (fresh) clearIdle(sidR.sid)
|
|
144
|
+
|
|
145
|
+
const end = nowSec() + timeoutSec
|
|
146
|
+
const marker = idleMarkerFor(sidR.sid)
|
|
147
|
+
while (nowSec() < end) {
|
|
148
|
+
if (existsSync(marker)) return { ok: true, via: 'marker' }
|
|
149
|
+
if (anchor.jsonl !== null && terminalAssistantAfter(anchor.jsonl, anchor.sinceBytes)) {
|
|
150
|
+
return { ok: true, via: 'jsonl' }
|
|
151
|
+
}
|
|
152
|
+
await sleepMs(3000)
|
|
153
|
+
}
|
|
154
|
+
return { ok: false }
|
|
155
|
+
}
|
|
156
|
+
|
|
18
157
|
/**
|
|
19
158
|
* Re-probe a teammate's liveness at the moment a sync wait is about to
|
|
20
159
|
* declare 124 ("expired, still running"). Two failure modes count as DEAD
|
package/src/engines/types.ts
CHANGED
|
@@ -277,7 +277,13 @@ export type TextResult =
|
|
|
277
277
|
export interface TeammateListing {
|
|
278
278
|
readonly name: TeammateName
|
|
279
279
|
readonly engine: EngineKind
|
|
280
|
-
|
|
280
|
+
/**
|
|
281
|
+
* Live turn state from the hook markers, or `killed` for an archived
|
|
282
|
+
* record surfaced by `tm ls --all` / `tm states --all` — that
|
|
283
|
+
* teammate has no live process; its identity snapshot survives kill
|
|
284
|
+
* so it stays resumable by name.
|
|
285
|
+
*/
|
|
286
|
+
readonly state: 'idle' | 'busy' | 'unknown' | 'killed'
|
|
281
287
|
/** Physical repo path the teammate is bound to. */
|
|
282
288
|
readonly repo: string
|
|
283
289
|
/** Runtime working directory (worktree path or `repo`). */
|
package/src/help.ts
CHANGED
|
@@ -39,8 +39,9 @@ USAGE (most common first)
|
|
|
39
39
|
tm kill <name> graceful /exit (clean worktree auto-removed);
|
|
40
40
|
dirty worktree preserved with stderr note
|
|
41
41
|
tm reload <name>... | --all fan out /reload-plugins
|
|
42
|
-
tm ls
|
|
43
|
-
|
|
42
|
+
tm ls [--all] list teammates (NAME REPO WORKTREE ENGINE STATE);
|
|
43
|
+
--all also lists killed teammates (resumable by name)
|
|
44
|
+
tm states [--all] rich fleet snapshot; --all includes killed teammates
|
|
44
45
|
tm ctx <name>... | --all real ctx-window usage from jsonl
|
|
45
46
|
tm history <name> [<sid/thread-prefix>] inspect past sessions for this teammate
|
|
46
47
|
tm mem <name> cat the parent repo's auto-memory index
|
|
@@ -70,20 +71,34 @@ ENVIRONMENT
|
|
|
70
71
|
|
|
71
72
|
/** Per-verb help text — `tm <verb> --help` and `tm help <verb>` both print this. */
|
|
72
73
|
export const HELP_TEXTS: Readonly<Record<string, string>> = {
|
|
73
|
-
ls: `tm ls
|
|
74
|
-
|
|
75
|
-
List
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
ls: `tm ls [--all]
|
|
75
|
+
|
|
76
|
+
List teammates, one row each: NAME, REPO (last path segment),
|
|
77
|
+
WORKTREE (slug or '-'), ENGINE (claude / codex), STATE (idle /
|
|
78
|
+
busy / unknown). The live fleet comes from each engine's session
|
|
79
|
+
listing. For a richer "who's doing what" view, prefer
|
|
80
|
+
\`tm states\`.
|
|
81
|
+
|
|
82
|
+
--all also lists killed teammates — the identity snapshots
|
|
83
|
+
\`tm kill\` archives under /tmp/teammate-archive/<name>.json —
|
|
84
|
+
with STATE 'killed'. Their NAME / REPO / WORKTREE are enough to
|
|
85
|
+
re-launch with \`tm resume <name>\`, so you recover a killed
|
|
86
|
+
session without scraping /tmp by hand. A name that is live again
|
|
87
|
+
shows its live row, not the stale killed one.
|
|
78
88
|
`,
|
|
79
|
-
states: `tm states
|
|
89
|
+
states: `tm states [--all]
|
|
80
90
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
91
|
+
Rich fleet snapshot, one row each: NAME, REPO, WORKTREE, ENGINE,
|
|
92
|
+
STATE, LAST (size + age of the last assistant reply), PREVIEW
|
|
93
|
+
(first chars of that reply). Claude reads
|
|
84
94
|
/tmp/claude-idle/<sid>.last; Codex reads the current thread's
|
|
85
95
|
rollout JSONL. Use to see what every teammate is doing at a
|
|
86
96
|
glance.
|
|
97
|
+
|
|
98
|
+
--all also lists killed teammates (STATE 'killed') from the
|
|
99
|
+
kill-time identity archive, same as \`tm ls --all\`; their LAST /
|
|
100
|
+
PREVIEW render as '-' because the live markers are gone. Recover
|
|
101
|
+
one with \`tm resume <name>\`.
|
|
87
102
|
`,
|
|
88
103
|
spawn: `tm spawn <path> [--name <id>] [--engine claude|codex] [--prompt "..."] [--no-worktree] [--timeout N]
|
|
89
104
|
|
|
@@ -112,6 +127,12 @@ export const HELP_TEXTS: Readonly<Record<string, string>> = {
|
|
|
112
127
|
useful for repo-wide work where a worktree would be a
|
|
113
128
|
negative-value isolation.
|
|
114
129
|
|
|
130
|
+
On a fresh launch the verb prints a \`base:\` line to stderr —
|
|
131
|
+
the branch + short sha the teammate starts from, plus a
|
|
132
|
+
best-effort ahead/behind vs the remote default branch. The base
|
|
133
|
+
is the repo's current HEAD, so a repo parked on a non-trunk
|
|
134
|
+
branch is visible at a glance rather than a silent surprise.
|
|
135
|
+
|
|
115
136
|
Without --prompt, the verb returns once the REPL signals
|
|
116
137
|
SessionStart (typically 2-4s on a warm Mac). With
|
|
117
138
|
\`--prompt "..."\`, the verb sleeps 3s after ready, sends the
|
|
@@ -126,11 +147,13 @@ export const HELP_TEXTS: Readonly<Record<string, string>> = {
|
|
|
126
147
|
\`.claude/worktrees/<name>/\` layout; they are not tmux sessions,
|
|
127
148
|
and \`--resume\` / \`--task\` are rejected on that path.
|
|
128
149
|
|
|
129
|
-
Every teammate launches with the \`AskUserQuestion
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
150
|
+
Every teammate launches with the \`AskUserQuestion\`,
|
|
151
|
+
\`EnterPlanMode\`, and \`ExitPlanMode\` tools disabled — a
|
|
152
|
+
teammate runs with no human at its terminal, and each of those
|
|
153
|
+
opens a modal that holds the turn open waiting for a person. A
|
|
154
|
+
teammate raises questions or proposes a plan by ending its turn
|
|
155
|
+
with text, which \`tm send\` / \`tm spawn --prompt\` relays
|
|
156
|
+
straight back to the dispatcher.
|
|
134
157
|
|
|
135
158
|
Exit codes on the \`--prompt\` sync path:
|
|
136
159
|
0 first-turn reply
|
|
@@ -157,6 +180,15 @@ export const HELP_TEXTS: Readonly<Record<string, string>> = {
|
|
|
157
180
|
need it — the Stop hook now covers them via PostCompact /
|
|
158
181
|
SessionEnd.
|
|
159
182
|
--timeout N overrides the 1800s default wait.
|
|
183
|
+
After sending, confirms the REPL accepted the prompt as a turn
|
|
184
|
+
(the on-busy marker, or a new user entry in the transcript) and
|
|
185
|
+
re-sends Enter up to 3x if not — so an Enter swallowed by a modal
|
|
186
|
+
surfaces a stderr warning instead of a silent wait. The wait then
|
|
187
|
+
unblocks on either the Stop-hook idle marker OR a settled turn in
|
|
188
|
+
the transcript jsonl, so a session whose Stop hook never loaded
|
|
189
|
+
still ends its wait on disk evidence instead of timing out. On
|
|
190
|
+
that no-hook path the reply is recovered from the transcript (and
|
|
191
|
+
written back to <sid>.last), so stdout still carries it.
|
|
160
192
|
Empty stdout never silently means success: a turn with no text
|
|
161
193
|
(tool-only, /compact, /clear) prints the sentinel line "(no
|
|
162
194
|
text reply this turn — tool-only, /compact, /clear, or fresh
|
|
@@ -259,6 +291,18 @@ export const HELP_TEXTS: Readonly<Record<string, string>> = {
|
|
|
259
291
|
AskUserQuestion tool disabled (see 'tm help spawn' for why): a
|
|
260
292
|
resumed teammate raises questions by ending its turn with
|
|
261
293
|
text, not by opening a modal.
|
|
294
|
+
|
|
295
|
+
Large-session startup prompt: resuming a big, hours-old Claude
|
|
296
|
+
session can make Claude Code raise a "Resume from summary
|
|
297
|
+
(recommended) / Resume full session as-is" startup selection
|
|
298
|
+
that waits for a choice — and a teammate has no human to answer
|
|
299
|
+
it. Teammates suppress it: 'tm spawn' / 'tm resume' launch with
|
|
300
|
+
CLAUDE_CODE_RESUME_TOKEN_THRESHOLD set far above any real window,
|
|
301
|
+
so the prompt never renders and the full session loads silently.
|
|
302
|
+
Fallback only — if a future Claude Code build ignores that knob
|
|
303
|
+
and the prompt reappears, confirm the REPL is at a normal input
|
|
304
|
+
box with 'tm status <name>' before the next 'tm send' (a blind
|
|
305
|
+
send's Enter would pick the summary and discard context).
|
|
262
306
|
`,
|
|
263
307
|
last: `tm last <name> [--verbose]
|
|
264
308
|
|
|
@@ -91,6 +91,9 @@ export function archivedIdentityFile(name: TeammateName): string {
|
|
|
91
91
|
/** Regex pinning the top-level identity-file name shape; capture group 1 is the name. */
|
|
92
92
|
const TOP_LEVEL_FILENAME = /^teammate-(.+)\.json$/
|
|
93
93
|
|
|
94
|
+
/** Regex pinning the archived identity-file name shape (`<name>.json`, no prefix). */
|
|
95
|
+
const ARCHIVE_FILENAME = /^(.+)\.json$/
|
|
96
|
+
|
|
94
97
|
export type ReserveResult =
|
|
95
98
|
| { kind: 'reserved' }
|
|
96
99
|
| { kind: 'taken'; existing: TeammateRecordJson }
|
|
@@ -232,6 +235,49 @@ export function list(): readonly TeammateRecordJson[] {
|
|
|
232
235
|
return out
|
|
233
236
|
}
|
|
234
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Enumerate every archived teammate record — the snapshots `tm kill`
|
|
240
|
+
* leaves behind under `<identityRoot>/teammate-archive/<name>.json`.
|
|
241
|
+
* `tm ls --all` / `tm states --all` use this to surface killed
|
|
242
|
+
* teammates that no live tmux session or Codex daemon would report,
|
|
243
|
+
* so the agent can recover a resumable name without scraping `/tmp`.
|
|
244
|
+
*
|
|
245
|
+
* The archive holds the LAST snapshot per name: re-spawning then
|
|
246
|
+
* re-killing a name overwrites its archive entry, and a name that is
|
|
247
|
+
* currently live also keeps its prior kill's snapshot here. Callers
|
|
248
|
+
* that merge this with the live fleet must dedupe by name, preferring
|
|
249
|
+
* the live row.
|
|
250
|
+
*
|
|
251
|
+
* Unparseable or schema-mismatched files are skipped, mirroring
|
|
252
|
+
* `list()`. The directory not existing yet (no teammate ever killed)
|
|
253
|
+
* returns an empty list, not an error.
|
|
254
|
+
*/
|
|
255
|
+
export function listArchived(): readonly TeammateRecordJson[] {
|
|
256
|
+
const out: TeammateRecordJson[] = []
|
|
257
|
+
const dir = archiveDir()
|
|
258
|
+
let entries: string[]
|
|
259
|
+
try {
|
|
260
|
+
entries = readdirSync(dir)
|
|
261
|
+
} catch {
|
|
262
|
+
return []
|
|
263
|
+
}
|
|
264
|
+
for (const entry of entries) {
|
|
265
|
+
if (ARCHIVE_FILENAME.exec(entry) === null) continue
|
|
266
|
+
let info: ReturnType<typeof statSync>
|
|
267
|
+
try {
|
|
268
|
+
info = statSync(join(dir, entry))
|
|
269
|
+
} catch {
|
|
270
|
+
continue
|
|
271
|
+
}
|
|
272
|
+
if (!info.isFile()) continue
|
|
273
|
+
const raw = readIfPresent(join(dir, entry))
|
|
274
|
+
if (raw === null) continue
|
|
275
|
+
const parsed = parse(raw)
|
|
276
|
+
if (parsed !== null) out.push(parsed)
|
|
277
|
+
}
|
|
278
|
+
return out
|
|
279
|
+
}
|
|
280
|
+
|
|
235
281
|
/**
|
|
236
282
|
* Parse a raw JSON file into a `TeammateRecordJson`. Returns `null` if
|
|
237
283
|
* the shape doesn't match the current schema. Schema 2 is the
|
package/src/verbs/format.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
TeammateStatus,
|
|
25
25
|
TurnResult,
|
|
26
26
|
} from '../engines/types'
|
|
27
|
+
import { listArchived } from '../persistence/identity-store'
|
|
27
28
|
import { EXIT_SYNC_WAIT_EXPIRED, type TmResult } from '../tm'
|
|
28
29
|
|
|
29
30
|
function rawTmResult(result: RawTmResult): TmResult | null {
|
|
@@ -60,6 +61,33 @@ function repoLeaf(path: string): string {
|
|
|
60
61
|
return leaf.length === 0 ? '-' : leaf
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Build the `killed` listing rows for `tm ls --all` / `tm states --all`:
|
|
66
|
+
* every archived identity record whose name is not already present in
|
|
67
|
+
* the live fleet. A killed teammate has no live markers, so the runtime
|
|
68
|
+
* `extras` (last / preview) are empty — the formatters render them as
|
|
69
|
+
* `-`. The name / repo / worktree / engine come straight from the
|
|
70
|
+
* archived record, which is enough to drive `tm resume <name>`.
|
|
71
|
+
*
|
|
72
|
+
* `liveNames` is the set of names the live `engine.list()` fan-out
|
|
73
|
+
* already returned; archived entries for those names are dropped so a
|
|
74
|
+
* re-spawned teammate shows its live row, not a stale killed one.
|
|
75
|
+
*/
|
|
76
|
+
export function archivedListingRows(liveNames: ReadonlySet<string>): TeammateListing[] {
|
|
77
|
+
return listArchived()
|
|
78
|
+
.filter((record) => !liveNames.has(record.name))
|
|
79
|
+
.map((record) => ({
|
|
80
|
+
name: record.name,
|
|
81
|
+
engine: record.engine,
|
|
82
|
+
state: 'killed' as const,
|
|
83
|
+
repo: record.repo,
|
|
84
|
+
cwd: record.cwd,
|
|
85
|
+
worktreeSlug: record.worktreeSlug,
|
|
86
|
+
displayName: record.displayName,
|
|
87
|
+
extras: {},
|
|
88
|
+
}))
|
|
89
|
+
}
|
|
90
|
+
|
|
63
91
|
export function formatListing(rows: readonly TeammateListing[]): TmResult {
|
|
64
92
|
if (rows.length === 0) {
|
|
65
93
|
return {
|
package/src/verbs/ls.ts
CHANGED
|
@@ -11,17 +11,24 @@
|
|
|
11
11
|
* the row shape, not the verb.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { formatListing, noEngineRegistered } from './format'
|
|
14
|
+
import { archivedListingRows, formatListing, noEngineRegistered } from './format'
|
|
15
15
|
import type { VerbContext } from './context'
|
|
16
16
|
import type { TmResult } from '../tm'
|
|
17
17
|
|
|
18
|
-
export
|
|
18
|
+
export interface LsOptions {
|
|
19
|
+
/** `--all`: also list killed teammates from the kill-time identity archive. */
|
|
20
|
+
readonly all: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function lsVerb(ctx: VerbContext, opts: LsOptions): Promise<TmResult> {
|
|
19
24
|
// An empty registry means production wiring is incomplete — the verb must
|
|
20
25
|
// surface that loudly, not silently report "no teammates". A zero-engine
|
|
21
26
|
// process is a misconfiguration, not a fleet state.
|
|
22
27
|
const engines = ctx.engines.registered()
|
|
23
28
|
if (engines.length === 0) return noEngineRegistered()
|
|
24
29
|
|
|
25
|
-
const
|
|
26
|
-
return formatListing(
|
|
30
|
+
const live = (await Promise.all(engines.map((engine) => engine.list(ctx.engineContext)))).flat()
|
|
31
|
+
if (!opts.all) return formatListing(live)
|
|
32
|
+
const liveNames = new Set(live.map((row) => row.name))
|
|
33
|
+
return formatListing([...live, ...archivedListingRows(liveNames)])
|
|
27
34
|
}
|
package/src/verbs/states.ts
CHANGED
|
@@ -14,9 +14,14 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { TmResult } from '../tm'
|
|
17
|
-
import { noEngineRegistered } from './format'
|
|
17
|
+
import { archivedListingRows, noEngineRegistered } from './format'
|
|
18
18
|
import type { VerbContext } from './context'
|
|
19
19
|
|
|
20
|
+
export interface StatesOptions {
|
|
21
|
+
/** `--all`: also list killed teammates from the kill-time identity archive. */
|
|
22
|
+
readonly all: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
type StatesRow = readonly [
|
|
21
26
|
string, string, string, string,
|
|
22
27
|
string, string, string,
|
|
@@ -47,13 +52,16 @@ function repoLeaf(path: string): string {
|
|
|
47
52
|
return leaf.length === 0 ? '-' : leaf
|
|
48
53
|
}
|
|
49
54
|
|
|
50
|
-
export async function statesVerb(ctx: VerbContext): Promise<TmResult> {
|
|
55
|
+
export async function statesVerb(ctx: VerbContext, opts: StatesOptions): Promise<TmResult> {
|
|
51
56
|
const engines = ctx.engines.registered()
|
|
52
57
|
if (engines.length === 0) return noEngineRegistered()
|
|
53
58
|
|
|
54
|
-
const
|
|
59
|
+
const live = (
|
|
55
60
|
await Promise.all(engines.map((engine) => engine.list(ctx.engineContext)))
|
|
56
61
|
).flat()
|
|
62
|
+
const listings = opts.all
|
|
63
|
+
? [...live, ...archivedListingRows(new Set(live.map((row) => row.name)))]
|
|
64
|
+
: live
|
|
57
65
|
|
|
58
66
|
if (listings.length === 0) return { code: 0, stdout: '(no teammate sessions)\n', stderr: '' }
|
|
59
67
|
|