@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 CHANGED
@@ -1,9 +1,14 @@
1
1
  {
2
2
  "name": "@excitedjs/tm",
3
- "version": "1.1.0",
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
  },
@@ -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
- return lsVerb(ctx)
119
- case 'states':
120
- return statesVerb(ctx)
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, waitIdleSignal, waitPaneQuiet } from './wait-signals'
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 waitIdleSignal(name, timeoutSec, false, env.runTmux)
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
@@ -277,7 +277,13 @@ export type TextResult =
277
277
  export interface TeammateListing {
278
278
  readonly name: TeammateName
279
279
  readonly engine: EngineKind
280
- readonly state: 'idle' | 'busy' | 'unknown'
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 list teammates (NAME REPO WORKTREE ENGINE STATE)
43
- tm states rich fleet snapshot
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 running teammate-<name> sessions. Shows tmux's raw session
76
- row (name, window count, attached state). For a richer "who's
77
- doing what" view, prefer \`tm states\`.
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
- One-line fleet snapshot: REPO, SID / thread id (first 8
82
- chars), BUSY, LAST (size + age of the last assistant reply),
83
- PREVIEW (first 50 chars of that reply). Claude reads
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\` tool
130
- disabled a teammate runs with no human at its terminal, and
131
- that modal would hold the turn open. A teammate raises
132
- questions by ending its turn with text, which \`tm send\` /
133
- \`tm spawn --prompt\` relays straight back to the dispatcher.
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
@@ -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 async function lsVerb(ctx: VerbContext): Promise<TmResult> {
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 listings = await Promise.all(engines.map((engine) => engine.list(ctx.engineContext)))
26
- return formatListing(listings.flat())
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
  }
@@ -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 listings = (
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