sisyphi 1.2.18 → 1.2.20

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.
@@ -1,3 +1,2 @@
1
- - **`reuse` dismissed entries cite `existing-file:line`** (the existing utility evaluated), not `file:line` (the new code) — the validation wave parses reuse dismissals differently from all other sub-agents.
2
1
  - **No output ≠ clean**: a sub-agent that produces no output is treated as failed. The explicit clean sentence ("No X concerns — ...") is the signal the validation wave uses to skip spawning a validator.
3
2
  - **Adding a sub-agent**: create `{name}.md` with frontmatter, add `subagent_type: {name}` to the scaling table in `review.md` step 4, and update the scaling guidance table if conditionally spawned — without the registration, the sub-agent is silently never spawned.
@@ -77,7 +77,7 @@ Run this pass on **every startup**, **before** entering resume logic.
77
77
 
78
78
  ### 1. Explore
79
79
 
80
- Use Bash + Glob + Grep + Read to explore the codebase relevant to the user's stated topic. Identify files, modules, and existing patterns that will be affected.
80
+ Use Bash + Glob + Grep + Read + the Agent tool to explore the codebase relevant to the user's stated topic. Identify files, modules, and existing patterns that will be affected.
81
81
 
82
82
  ### 2. Gauge Clarity
83
83
 
@@ -227,7 +227,7 @@ Dispatch a single `requirements-writer` subagent for the entire design (see "Sub
227
227
 
228
228
  Validate the chunk (parseable JSON + `groups` array where each group has `id` and `requirements`). If invalid, increment N and re-dispatch; if it fails twice, bail.
229
229
 
230
- **Snapshot prior approvals before merging.** If `context/requirements.json` already exists (this is a re-dispatch — §5.1 bounce-return or §5.2 writer-redispatch), read its current `groups[].requirements[]` and `groups[].safeAssumptions[]` and build an in-memory **approval map**: for every item where `status === 'approved'` AND `userNotes` is empty/absent, record `contentKey(item) → true`. The content key is the SHA-256 of the canonical-JSON `{title, ears}` after normalizing every string with `trim()` + collapse runs of whitespace to a single space. Items with `status: 'draft'`/`'rejected'` or non-empty `userNotes` are NOT added — the re-dispatch was triggered by their unresolved state, and the writer is meant to revisit them.
230
+ **Snapshot prior approvals before merging.** If `context/requirements.json` already exists (this is a re-dispatch — §5.1 bounce-return or §5.2 fresh-writer escalation; the §5.2 lead-patch path never reaches here), read its current `groups[].requirements[]` and `groups[].safeAssumptions[]` and build an in-memory **approval map**: for every item where `status === 'approved'` AND `userNotes` is empty/absent, record `contentKey(item) → true`. The content key is the SHA-256 of the canonical-JSON `{title, ears}` after normalizing every string with `trim()` + collapse runs of whitespace to a single space. Items with `status: 'draft'`/`'rejected'` or non-empty `userNotes` are NOT added — the re-dispatch was triggered by their unresolved state, and the writer is meant to revisit them.
231
231
 
232
232
  **Merge.** Replace `groups` entirely with the new chunk; preserve `meta`. Set `meta.stage = 'stage-2-in-progress'`. Then walk the new `groups[].requirements[]` and `groups[].safeAssumptions[]`: for each item, compute its content key; if the approval map contains it, set `status = 'approved'` and clear `userNotes`. Otherwise leave whatever the writer emitted untouched. Delete the chunk file.
233
233
 
@@ -283,22 +283,33 @@ With `meta.stage === 'stage-2-verdict-pending'`:
283
283
 
284
284
  1. All requirements (incl. safeAssumptions) `status === 'approved'` → atomic-write `meta.stage = 'stage-2-done'`; proceed to Stage 3.
285
285
  2. Any `status === 'rejected'` → §5.1 bounce-to-design.
286
- 3. Else (some `draft` with non-empty `userNotes`, no `rejected`) → §5.2 writer re-dispatch.
286
+ 3. Else (some `draft` with non-empty `userNotes`, no `rejected`) → §5.2 comment resolution.
287
287
 
288
288
  ### §5.1 Bounce-to-design
289
289
 
290
290
  Increment `meta.bounceIterations` (init 0 if absent; **never decrements**). If new value > 3, bail. Quote rejected items + `userNotes` to user. Dispatch engineer in revision mode (Stage 1 revision contract) with rejected items as feedback. After engineer returns, run `crtr human show` to display the revised design; re-sign-off. Re-render to text via `hl doc render`. Atomic-write `meta.stage = 'stage-2-in-progress'`. Return to Step 2. REQ ids may shift — each pass is independent. User comments flow to the engineer; the writer re-extracts from the revised design.
291
291
 
292
- ### §5.2 Writer re-dispatch
292
+ ### §5.2 Comment resolution
293
293
 
294
- Increment `meta.writerRedispatchIterations` (init 0 if absent; **never decrements**). If new value > 3, bail: `"Stage 2 writer-redispatch cap reached after 3 passes. Latest comments preserved in requirements.json. Re-spawn spec fresh or escalate."` Atomic-write `meta.stage = 'writer-redispatch-pending'` BEFORE re-dispatching. Tell user: `"Draft comments noted but the writer re-extracts from design re-flag any items it missed on the next pass."` After writer chunk merge (which resets `meta.stage = 'stage-2-in-progress'`), return to Step 3.
294
+ The design is unchanged here design-level objections route through bounce-to-design (§5.1, where the user picked "Bounce"). What remains is `draft` items carrying `userNotes`. **Resolve them in place by default; escalate to a fresh writer only when the comments demand broad re-extraction.** Patching in place is what keeps already-approved items from being reworded and re-asked: the writer regenerates the whole document and its wording drifts, so re-dispatching it for a localized tweak forces the human to re-approve everything.
295
+
296
+ **Triage the commented items.** Classify every `draft`-with-`userNotes` item:
297
+
298
+ - **Localized** — satisfiable by editing that requirement's own fields: reword its EARS clause, fix/add/remove a criterion, tighten or split its scope, correct a detail, clarify wording.
299
+ - **Broad** — implies behavior not captured anywhere (a coverage gap / a missing requirement), a regrouping or restructure across the document, or otherwise can't be met by editing the named items alone. (If a comment reveals the *design* is wrong rather than the requirement text, that is a bounce — tell the user and route to §5.1.)
300
+
301
+ **All comments localized → lead patch (no writer).** Edit each commented requirement in place to satisfy its `userNotes`. Hold the same bar as the writer (`agents/spec/requirements-writer.md`): keep the EARS shape valid (exactly one of `when`/`while`/`if`/`where` plus `shall`) and behavioral, not technical — verbs an external observer sees; no function names, file paths, or algorithms. Stay within the approved design; do not invent behavior it doesn't support. For each patched item: apply the edit, record a one-line rationale in `agentNotes` (e.g. `Revised per review: <what changed>`), clear `userNotes`, and set `status = 'draft'`. **Leave every `approved` item byte-for-byte untouched** — do not run the Step 2 snapshot/merge; that path is for the writer only. Atomic-write `requirements.json` with `meta.stage = 'stage-2-in-progress'` and return to Step 3. Only the patched items reappear as fresh decisions; approved items keep their `status` and surface with the `preAnswered` ◆ marker. Tell the user: `"Revised the N commented requirement(s) in place — only those are back for review; previously-approved items carry forward (◆)."`
302
+
303
+ **Any comment broad → fresh-writer escalation.** Increment `meta.writerRedispatchIterations` (init 0 if absent; **never decrements**). If new value > 3, bail: `"Stage 2 writer-redispatch cap reached after 3 passes. Latest comments preserved in requirements.json. Re-spawn spec fresh or escalate."` Atomic-write `meta.stage = 'writer-redispatch-pending'` BEFORE re-dispatching. Tell user: `"Comments span more than the named items — re-running the writer over the design; re-flag anything it misses on the next pass."` After the writer chunk merge (Step 2, which resets `meta.stage = 'stage-2-in-progress'`), return to Step 3.
304
+
305
+ **Patch not converging.** If an item you already patched comes back with another comment, the localized edit isn't landing — escalate that round via fresh-writer (or bounce via §5.1 if the new comment points at the design) rather than patching the same item a third time.
295
306
 
296
307
  ### Step 6 — Stage-2 state-machine table
297
308
 
298
309
  | `meta.stage` | Set at | Resume action on lead respawn |
299
310
  |---|---|---|
300
- | `stage-2-in-progress` | Step 2 merge / §5.1 bounce-returns / §5.2 writer-merge | If `meta.openAskId` set → Resume Logic re-attach; else → Step 3 (issue review deck) |
301
- | `writer-redispatch-pending` | §5.2 entry | §5.2 writer re-dispatch |
311
+ | `stage-2-in-progress` | Step 2 merge / §5.1 bounce-returns / §5.2 writer-merge / §5.2 lead patch | If `meta.openAskId` set → Resume Logic re-attach; else → Step 3 (issue review deck) |
312
+ | `writer-redispatch-pending` | §5.2 fresh-writer escalation entry | §5.2 fresh-writer re-dispatch |
302
313
  | `stage-2-verdict-pending` | Step 4 atomic writeback | Step 5 verdict |
303
314
  | `stage-2-done` | Step 5 case 1 | Stage 3 entry |
304
315
 
@@ -14,9 +14,17 @@ You are an agent in a sisyphus session.
14
14
 
15
15
  If you're blocked by ambiguity, contradictions, or unclear requirements — **don't guess**. Submit what you found instead. A clear report is more valuable than a wrong implementation.
16
16
 
17
+ ## The sis CLI
18
+
19
+ You operate inside a sisyphus session driven by the `sis` CLI. Run `sis <group> -h` to drill into any command.
20
+
21
+ {{HELP:.}}
22
+
17
23
  ## The User
18
24
 
19
- A human may interact with you directly in your pane — if they do, prioritize their input over your original instruction. Otherwise, communicate through the orchestrator via reports.
25
+ A human may interact with you directly in your pane — if they do, prioritize their input over your original instruction. Otherwise, communicate through the orchestrator via reports.
26
+
27
+ **If the user complains about sisyphus itself** — a crash, a CLI that misbehaved, a confusing or broken workflow, behavior they disliked — file it with `sis feedback "<summary>"` (run `sis feedback -h`). Low-cost side-action; do it without asking. It's for the tool, not your task.
20
28
 
21
29
  ## Context
22
30
 
@@ -101,6 +101,16 @@ goal.md is a plain statement of what "done" looks like — scope boundaries and
101
101
  **What belongs in goal.md:** the desired end state, what's in scope, what's out of scope.
102
102
  **What doesn't:** approach decisions, technical choices, stage plans — those belong in strategy.md and context docs.
103
103
 
104
+ **Scope files keep goal.md lean.** goal.md is inlined into every wakeup and capped at 100 lines, so concrete detail about one slice of the goal does not belong inline — it taxes every cycle, including ones abstracted far away from that slice. When a part of the goal needs maintained detail (a subsystem, a workstream, a newly-authorized expansion), write `context/scope-<topic>.md` for it and add a one-line pointer under a `## Scope` list in goal.md:
105
+
106
+ ```
107
+ ## Scope
108
+ - context/scope-backend.md — DB + API-layer refactors for X
109
+ - context/scope-frontend.md — render-path cleanup for X
110
+ ```
111
+
112
+ This is how scope *grows* without rewriting the goal: a mid-session "let's also do the microservices" becomes a new scope file linked from goal.md, never a condensed or deleted goal. Scope files are maintained like other context docs (current understanding, not history) and read on demand; strategy.md and roadmap.md point at the scope file a stage is focused on rather than restating it. A hook rejects any goal.md edit that leaves the file over 100 lines and tells you to offload into scope files.
113
+
104
114
  ### strategy.md — Your problem-solving map
105
115
 
106
116
  strategy.md defines **how to approach this problem** — the stages, gates, backtrack edges, and behavioral style for this session. It is generated during discovery and progressively updated as the goal crystallizes or shifts.
@@ -225,6 +235,7 @@ Context dir contents are listed in your prompt each cycle. Read files when you n
225
235
  - Roadmap items should **reference** context files: `"See context/{plan-lead-agent-id}/plan-stage-1-auth.md for detail."` Copy the path from the plan lead's submission report; don't reconstruct it.
226
236
  - Agents writing requirements and designs save to the context dir with descriptive filenames: `requirements-auth.md`, `design-auth.md`. Plan agents save plans under their own subdirectory `context/{agent-id}/plan-*.md`; treat those paths as authoritative from the plan lead's report.
227
237
  - **Implementation plans belong here**, not in roadmap.md
238
+ - **Scope files** (`context/scope-<topic>.md`) hold maintained detail for one slice of the goal, linked from goal.md's `## Scope` list — see the goal.md section. You write and maintain them directly, like strategy.md.
228
239
 
229
240
  ### Session Directory
230
241
 
@@ -284,6 +295,8 @@ You have unlimited cycles. Failed implementations, deferred issues, and skipped
284
295
 
285
296
  ## CLI Reference
286
297
 
298
+ {{HELP:.}}
299
+
287
300
  {{HELP:session clone}}
288
301
 
289
302
  ## File Conflicts
@@ -0,0 +1,60 @@
1
+ #!/bin/bash
2
+ # PostToolUse(Write|Edit|MultiEdit) hook for the orchestrator: enforce the
3
+ # 100-line cap on goal.md. The edit has already landed (PostToolUse runs after
4
+ # success); if goal.md is now over the cap, emit a decision:block so the
5
+ # orchestrator must trim it and move the detail into context/scope-*.md before
6
+ # proceeding. Under the cap → silent passthrough.
7
+
8
+ if [ -z "$SISYPHUS_SESSION_ID" ] || [ -z "$SISYPHUS_SESSION_DIR" ]; then exit 0; fi
9
+
10
+ STDIN_JSON=$(cat)
11
+
12
+ FP=$(printf '%s' "$STDIN_JSON" | python3 -c "
13
+ import json, sys
14
+ try:
15
+ d = json.load(sys.stdin)
16
+ print((d.get('tool_input') or {}).get('file_path') or '')
17
+ except Exception:
18
+ pass
19
+ " 2>/dev/null)
20
+
21
+ [ -z "$FP" ] && exit 0
22
+
23
+ GOAL_FILE="$SISYPHUS_SESSION_DIR/goal.md"
24
+
25
+ SAME=$(python3 -c "
26
+ import os, sys
27
+ try:
28
+ print('1' if os.path.realpath(sys.argv[1]) == os.path.realpath(sys.argv[2]) else '0')
29
+ except Exception:
30
+ print('0')
31
+ " "$FP" "$GOAL_FILE" 2>/dev/null)
32
+
33
+ [ "$SAME" = "1" ] || exit 0
34
+ [ -f "$GOAL_FILE" ] || exit 0
35
+
36
+ LINES=$(python3 -c "
37
+ import sys
38
+ try:
39
+ with open(sys.argv[1], encoding='utf-8') as f:
40
+ print(sum(1 for _ in f))
41
+ except Exception:
42
+ print(0)
43
+ " "$GOAL_FILE" 2>/dev/null)
44
+
45
+ [ -z "$LINES" ] && exit 0
46
+ if [ "$LINES" -le 100 ]; then exit 0; fi
47
+
48
+ REASON=$(cat <<TXT
49
+ goal.md is now ${LINES} lines — over the 100-line cap. Trim it back to the north-star paragraph plus a "## Scope" reference list before continuing.
50
+
51
+ Move the detail you just added into context/scope-<topic>.md (the maintained home for one slice of the goal) and leave only a one-line pointer in goal.md:
52
+ - context/scope-<topic>.md — one-line description
53
+
54
+ Why the cap: goal.md is inlined into every orchestrator wakeup, so length here taxes every future cycle, even ones working far from this detail. Scope files are read on demand and can be referenced from strategy.md / roadmap.md. Restructure the over-cap detail into scope files rather than deleting still-relevant scope to fit.
55
+ TXT
56
+ )
57
+
58
+ ESCAPED=$(printf '%s' "$REASON" | python3 -c "import json,sys; print(json.dumps(sys.stdin.read()))")
59
+ echo "{\"decision\":\"block\",\"reason\":$ESCAPED,\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\"}}"
60
+ exit 0
@@ -0,0 +1,54 @@
1
+ #!/bin/bash
2
+ # PostToolUse(Read) hook for the orchestrator: when the orchestrator reads
3
+ # goal.md, surface the scope-file convention. goal.md is inlined into every
4
+ # wakeup, so the orchestrator only Reads it when it intends to edit — that's
5
+ # the moment to remind it to push concrete detail into context/scope-*.md
6
+ # rather than into the goal. Neutral guidance (additionalContext), not a block.
7
+
8
+ if [ -z "$SISYPHUS_SESSION_ID" ] || [ -z "$SISYPHUS_SESSION_DIR" ]; then exit 0; fi
9
+
10
+ STDIN_JSON=$(cat)
11
+
12
+ FP=$(printf '%s' "$STDIN_JSON" | python3 -c "
13
+ import json, sys
14
+ try:
15
+ d = json.load(sys.stdin)
16
+ print((d.get('tool_input') or {}).get('file_path') or '')
17
+ except Exception:
18
+ pass
19
+ " 2>/dev/null)
20
+
21
+ [ -z "$FP" ] && exit 0
22
+
23
+ GOAL_FILE="$SISYPHUS_SESSION_DIR/goal.md"
24
+
25
+ # Only fire for the session's own goal.md (compare resolved paths).
26
+ SAME=$(python3 -c "
27
+ import os, sys
28
+ try:
29
+ print('1' if os.path.realpath(sys.argv[1]) == os.path.realpath(sys.argv[2]) else '0')
30
+ except Exception:
31
+ print('0')
32
+ " "$FP" "$GOAL_FILE" 2>/dev/null)
33
+
34
+ [ "$SAME" = "1" ] || exit 0
35
+
36
+ ADVISORY=$(cat <<'TXT'
37
+ Editing goal.md? Keep it to the north-star paragraph plus a `## Scope` list of references — nothing more. Put any concrete detail about one slice of the goal (a subsystem, a workstream, a newly-authorized expansion) in `context/scope-<topic>.md`, and link it from goal.md with a one-liner:
38
+ - context/scope-backend.md — DB + API-layer refactors for X
39
+ - context/scope-frontend.md — render-path cleanup for X
40
+
41
+ Why: goal.md is inlined into every orchestrator wakeup and capped at 100 lines, so per-slice detail here taxes every future cycle — even ones working far from that slice. Routing detail into scope files lets scope grow without rewriting the goal: a mid-session "let's also do the microservices" becomes a new scope file linked from goal.md, never a condensed or deleted goal. Maintain scope files like other context docs (current understanding, not history); they are read on demand, and strategy.md/roadmap.md point at whichever scope a stage is focused on.
42
+ TXT
43
+ )
44
+
45
+ printf '%s' "$ADVISORY" | python3 -c "
46
+ import json, sys
47
+ print(json.dumps({
48
+ 'hookSpecificOutput': {
49
+ 'hookEventName': 'PostToolUse',
50
+ 'additionalContext': sys.stdin.read(),
51
+ }
52
+ }))
53
+ "
54
+ exit 0
@@ -9,6 +9,26 @@
9
9
  }
10
10
  ]
11
11
  }
12
+ ],
13
+ "PostToolUse": [
14
+ {
15
+ "matcher": "Read",
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/goal-read-advisory.sh"
20
+ }
21
+ ]
22
+ },
23
+ {
24
+ "matcher": "Write|Edit|MultiEdit",
25
+ "hooks": [
26
+ {
27
+ "type": "command",
28
+ "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/goal-length-guard.sh"
29
+ }
30
+ ]
31
+ }
12
32
  ]
13
33
  }
14
34
  }
package/dist/tui.js CHANGED
@@ -55,6 +55,11 @@ function setupTerminal() {
55
55
  console.error(err);
56
56
  process.exit(1);
57
57
  });
58
+ process.on("unhandledRejection", (reason) => {
59
+ cleanup2();
60
+ console.error(reason);
61
+ process.exit(1);
62
+ });
58
63
  return cleanup2;
59
64
  }
60
65
  function writeToStdout(data) {
@@ -309,7 +314,7 @@ var init_paths = __esm({
309
314
  });
310
315
 
311
316
  // src/shared/platform.ts
312
- import { execSync } from "child_process";
317
+ import { execFileSync, execSync } from "child_process";
313
318
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
314
319
  function detectPlatform() {
315
320
  if (cachedPlatform) return cachedPlatform;
@@ -892,7 +897,7 @@ var init_notify = __esm({
892
897
  });
893
898
 
894
899
  // src/daemon/ask-store.ts
895
- import { existsSync as existsSync9, mkdirSync as mkdirSync8, readFileSync as readFileSync13, readdirSync as readdirSync6 } from "fs";
900
+ import { existsSync as existsSync9, mkdirSync as mkdirSync8, readFileSync as readFileSync13, readdirSync as readdirSync6, unlinkSync } from "fs";
896
901
  import { basename as basename3 } from "path";
897
902
  function readDecisions(cwd2, sessionId2, askId2) {
898
903
  const p = askDecisionsPath(cwd2, sessionId2, askId2);
@@ -1200,7 +1205,6 @@ function createAppState(cwd2) {
1200
1205
  const strategyScroll = new ThrottledScroll(requestRender);
1201
1206
  const roadmapScroll = new ThrottledScroll(requestRender);
1202
1207
  const expanded = /* @__PURE__ */ new Set();
1203
- expanded.add("section:needs-you");
1204
1208
  expanded.add("section:running");
1205
1209
  return {
1206
1210
  rows,
@@ -1261,6 +1265,8 @@ function createAppState(cwd2) {
1261
1265
  inlineDeck: null,
1262
1266
  visuals: /* @__PURE__ */ new Map(),
1263
1267
  reviewPanel: null,
1268
+ pendingFocus: null,
1269
+ inlineDeckStartAskId: null,
1264
1270
  cwd: cwd2
1265
1271
  };
1266
1272
  }
@@ -1471,7 +1477,7 @@ async function exportSessionToZip(sessionId2, cwd2, options) {
1471
1477
 
1472
1478
  // src/shared/clipboard.ts
1473
1479
  init_platform();
1474
- import { execFileSync, spawnSync } from "child_process";
1480
+ import { execFileSync as execFileSync2, spawnSync } from "child_process";
1475
1481
  function detectClipboard() {
1476
1482
  const platform = detectPlatform();
1477
1483
  if (platform === "darwin") {
@@ -1535,7 +1541,7 @@ function copyToClipboard(text) {
1535
1541
  return { reason: c.hint === null ? "No clipboard backend available" : c.hint };
1536
1542
  }
1537
1543
  try {
1538
- execFileSync(c.copy.cmd, c.copy.args, { input: text, stdio: ["pipe", "ignore", "pipe"] });
1544
+ execFileSync2(c.copy.cmd, c.copy.args, { input: text, stdio: ["pipe", "ignore", "pipe"] });
1539
1545
  return null;
1540
1546
  } catch (err) {
1541
1547
  const msg = err instanceof Error ? err.message.split("\n")[0] : String(err);
@@ -1865,28 +1871,12 @@ function sessionSortKey(s) {
1865
1871
  }
1866
1872
  function buildTree(sessions, selectedSession, expanded, cwd2, polledContextFiles = [], aggregateInbox = []) {
1867
1873
  const nodes = [];
1868
- const inboxBySession = /* @__PURE__ */ new Map();
1869
- for (const item of aggregateInbox) {
1870
- const sessionId2 = sessionIdFromDir(item.dir);
1871
- const arr = inboxBySession.get(sessionId2) ?? [];
1872
- arr.push(item);
1873
- inboxBySession.set(sessionId2, arr);
1874
- }
1875
- const needsYou = [];
1876
1874
  const running = [];
1877
1875
  const done = [];
1878
1876
  for (const s of sessions) {
1879
- if (inboxBySession.has(s.id)) needsYou.push(s);
1880
- else if (s.status === "completed") done.push(s);
1877
+ if (s.status === "completed") done.push(s);
1881
1878
  else running.push(s);
1882
1879
  }
1883
- needsYou.sort((a, b) => {
1884
- const aItems = inboxBySession.get(a.id);
1885
- const bItems = inboxBySession.get(b.id);
1886
- const aOldest = Math.min(...aItems.map((i) => Date.parse(i.blockedSince)));
1887
- const bOldest = Math.min(...bItems.map((i) => Date.parse(i.blockedSince)));
1888
- return aOldest - bOldest;
1889
- });
1890
1880
  running.sort((a, b) => {
1891
1881
  const k = sessionSortKey(a) - sessionSortKey(b);
1892
1882
  if (k !== 0) return k;
@@ -2039,7 +2029,7 @@ function buildTree(sessions, selectedSession, expanded, cwd2, polledContextFiles
2039
2029
  }
2040
2030
  }
2041
2031
  }
2042
- function emitSessionRow(s, askCount) {
2032
+ function emitSessionRow(s) {
2043
2033
  const sessionNodeId = `session:${s.id}`;
2044
2034
  const isSelected = selectedSession?.id === s.id;
2045
2035
  const isExpanded = expanded.has(sessionNodeId);
@@ -2059,38 +2049,31 @@ function buildTree(sessions, selectedSession, expanded, cwd2, polledContextFiles
2059
2049
  createdAt: s.createdAt,
2060
2050
  completedAt: isSelected ? selectedSession?.completedAt : void 0,
2061
2051
  activeMs: isSelected ? selectedSession?.activeMs ?? s.activeMs : s.activeMs,
2062
- askCount: askCount > 0 ? askCount : void 0,
2063
2052
  orphaned: s.orphaned ?? false
2064
2053
  });
2065
2054
  if (isExpanded && isSelected) {
2066
2055
  emitSessionChildren(s);
2067
2056
  }
2068
2057
  }
2069
- emitSection("needs-you", needsYou.length);
2070
- if (sectionExpanded("needs-you")) {
2071
- nodes.push({
2072
- id: "needs-you-virtual",
2073
- type: "needs-you-virtual",
2074
- depth: 1,
2075
- expandable: false,
2076
- expanded: false,
2077
- sessionId: "",
2078
- pendingCount: aggregateInbox.length
2079
- });
2080
- for (const s of needsYou) {
2081
- emitSessionRow(s, inboxBySession.get(s.id)?.length ?? 0);
2082
- }
2083
- }
2058
+ nodes.push({
2059
+ id: "needs-you-virtual",
2060
+ type: "needs-you-virtual",
2061
+ depth: 0,
2062
+ expandable: false,
2063
+ expanded: false,
2064
+ sessionId: "",
2065
+ pendingCount: aggregateInbox.length
2066
+ });
2084
2067
  emitSection("running", running.length);
2085
2068
  if (sectionExpanded("running")) {
2086
2069
  for (const s of running) {
2087
- emitSessionRow(s, 0);
2070
+ emitSessionRow(s);
2088
2071
  }
2089
2072
  }
2090
2073
  emitSection("done", done.length);
2091
2074
  if (sectionExpanded("done")) {
2092
2075
  for (const s of done) {
2093
- emitSessionRow(s, 0);
2076
+ emitSessionRow(s);
2094
2077
  }
2095
2078
  }
2096
2079
  return nodes;
@@ -4909,8 +4892,8 @@ function handleInlineDeckKey(input, key, state2, actions) {
4909
4892
  handle.unmount();
4910
4893
  const nodes = actions.getNodes();
4911
4894
  const i = nodes.findIndex((n) => n.id === "needs-you-virtual");
4912
- const prev = i > 0 ? nodes[i - 1] : nodes.find((n) => n.type === "session") ?? nodes[0];
4913
- state2.cursorNodeId = prev?.id ?? null;
4895
+ const next = nodes.find((n) => n.type === "session") ?? nodes[i + 1] ?? nodes[0];
4896
+ state2.cursorNodeId = next?.id ?? null;
4914
4897
  state2.focusPane = "tree";
4915
4898
  requestRender();
4916
4899
  return;
@@ -5530,11 +5513,16 @@ function rawSend(request, timeoutMs = 1e4) {
5530
5513
  function send(request) {
5531
5514
  return rawSend(request, 8e3);
5532
5515
  }
5533
- async function inboxList() {
5534
- const res = await send({ type: "inbox-list" });
5516
+ async function inboxList(cwd2) {
5517
+ const res = await send({ type: "inbox-list", cwd: cwd2 });
5535
5518
  if (!res.ok) return [];
5536
5519
  return res.data?.items ?? [];
5537
5520
  }
5521
+ async function focusGet(cwd2) {
5522
+ const res = await send({ type: "focus-get", cwd: cwd2 });
5523
+ if (!res.ok) return null;
5524
+ return res.data?.focus ?? null;
5525
+ }
5538
5526
 
5539
5527
  // src/tui/lib/tmux.ts
5540
5528
  init_paths();
@@ -5845,15 +5833,6 @@ function renderNodeContent(node, maxWidth) {
5845
5833
  switch (node.type) {
5846
5834
  case "section": {
5847
5835
  switch (node.section) {
5848
- case "needs-you":
5849
- return {
5850
- icon: "",
5851
- label: "Needs You",
5852
- meta: node.count > 0 ? `${node.count}` : "",
5853
- color: node.count > 0 ? "red" : "gray",
5854
- dim: false,
5855
- metaColor: "red"
5856
- };
5857
5836
  case "running":
5858
5837
  return {
5859
5838
  icon: "",
@@ -5891,15 +5870,13 @@ function renderNodeContent(node, maxWidth) {
5891
5870
  const cyclePart = node.cycleCount > 0 ? `C${node.cycleCount}` : "";
5892
5871
  const dur = formatDuration(node.activeMs);
5893
5872
  const agopart = node.status === "completed" && node.completedAt ? formatTimeAgo(node.completedAt) : "";
5894
- const askBadge = node.askCount ? `!${node.askCount}` : "";
5895
- const meta = [askBadge, cyclePart, dur, agopart].filter(Boolean).join(" ");
5896
- const metaColor = node.askCount ? "red" : void 0;
5873
+ const meta = [cyclePart, dur, agopart].filter(Boolean).join(" ");
5897
5874
  const suffix = node.orphaned ? "\u26A0 orphan" : void 0;
5898
5875
  const suffixColor = node.orphaned ? "red" : void 0;
5899
5876
  const displayText = node.name ?? node.task;
5900
5877
  const suffixWidth = suffix ? suffix.length + 1 : 0;
5901
5878
  const maxLabel = Math.max(8, maxWidth - meta.length - 4 - suffixWidth);
5902
- return { icon, label: truncate(displayText, maxLabel), meta, color, dim: dim2, metaColor, suffix, suffixColor };
5879
+ return { icon, label: truncate(displayText, maxLabel), meta, color, dim: dim2, suffix, suffixColor };
5903
5880
  }
5904
5881
  case "cycle": {
5905
5882
  const isRunning = !node.completedAt;
@@ -7341,7 +7318,7 @@ function renderFleetRollup(rect, state2, focused) {
7341
7318
  }
7342
7319
  const uniqueSessions = new Set(items.map((i) => sessionIdFromDir(i.dir))).size;
7343
7320
  lines = [];
7344
- lines.push([seg(" Fleet Inbox", { color: "red", bold: true })]);
7321
+ lines.push([seg(" Inbox", { color: "red", bold: true })]);
7345
7322
  lines.push(singleLine(` ${items.length} pending across ${uniqueSessions} sessions`, { dim: true }));
7346
7323
  lines.push(singleLine(" "));
7347
7324
  lines.push([seg(" By Type", { color: "cyan", bold: true })]);
@@ -8312,9 +8289,20 @@ function mountResolutionPanel(opts, state2) {
8312
8289
  };
8313
8290
  return deck;
8314
8291
  }
8292
+ function firstBuildableFrom(start) {
8293
+ if (queue.length === 0) return null;
8294
+ for (let off = 0; off < queue.length; off++) {
8295
+ const idx = (start + off) % queue.length;
8296
+ const deck = buildDeck(idx);
8297
+ if (deck) return { idx, deck };
8298
+ }
8299
+ return null;
8300
+ }
8301
+ const initial = firstBuildableFrom(currentIndex);
8302
+ if (!initial) return null;
8303
+ currentIndex = initial.idx;
8304
+ const initialDeck = initial.deck;
8315
8305
  const { cwd: initCwd, sessionId: initSessionId, askId: initAskId } = itemCoords(item());
8316
- const initialDeck = buildDeck(currentIndex);
8317
- if (!initialDeck) return null;
8318
8306
  let currentDeck = initialDeck;
8319
8307
  const initialProgress = readProgress(initCwd, initSessionId, initAskId);
8320
8308
  let answeredCount = initialProgress?.responses.length ?? 0;
@@ -8375,14 +8363,14 @@ function mountResolutionPanel(opts, state2) {
8375
8363
  teardown();
8376
8364
  return;
8377
8365
  }
8378
- currentIndex = Math.min(currentIndex, queue.length - 1);
8379
- const nextItem = queue[currentIndex];
8380
- const nextCoords = itemCoords(nextItem);
8381
- const nextDeck = buildDeck(currentIndex);
8382
- if (!nextDeck) {
8366
+ const next = firstBuildableFrom(Math.min(currentIndex, queue.length - 1));
8367
+ if (!next) {
8383
8368
  teardown();
8384
8369
  return;
8385
8370
  }
8371
+ currentIndex = next.idx;
8372
+ const nextDeck = next.deck;
8373
+ const nextCoords = itemCoords(queue[currentIndex]);
8386
8374
  currentDeck = nextDeck;
8387
8375
  const nextProgress = readProgress(nextCoords.cwd, nextCoords.sessionId, nextCoords.askId);
8388
8376
  answeredCount = nextProgress?.responses.length ?? 0;
@@ -8393,7 +8381,10 @@ function mountResolutionPanel(opts, state2) {
8393
8381
  });
8394
8382
  setDeckWatch(nextCoords, nextDeck);
8395
8383
  requestRender();
8396
- })();
8384
+ })().catch((err) => {
8385
+ notify(state2, `Failed to submit answer: ${err instanceof Error ? err.message : String(err)}`);
8386
+ requestRender();
8387
+ });
8397
8388
  };
8398
8389
  let watchedDeckPath = null;
8399
8390
  let lastWatchedDeckJson = "";
@@ -8437,7 +8428,8 @@ function mountResolutionPanel(opts, state2) {
8437
8428
  const { cwd: cwd2, sessionId: sessionId2, askId: askId2 } = itemCoords(it);
8438
8429
  const cur = readMeta(cwd2, sessionId2, askId2);
8439
8430
  if (cur?.status === "pending") {
8440
- void updateMeta(cwd2, sessionId2, askId2, { status: "in-progress", startedAt: (/* @__PURE__ */ new Date()).toISOString() });
8431
+ void updateMeta(cwd2, sessionId2, askId2, { status: "in-progress", startedAt: (/* @__PURE__ */ new Date()).toISOString() }).catch(() => {
8432
+ });
8441
8433
  }
8442
8434
  },
8443
8435
  onComplete: (responses) => {
@@ -8617,22 +8609,23 @@ function mountReviewActionPanel(opts) {
8617
8609
  // src/tui/panels/inbox-deck.ts
8618
8610
  var lastMountDims = null;
8619
8611
  function mountInlineDeck(state2, cols, rows) {
8612
+ const deckQueue = state2.aggregateInbox.filter((i) => i.kind !== "review");
8613
+ let startIndex = 0;
8614
+ if (state2.inlineDeckStartAskId) {
8615
+ const idx = deckQueue.findIndex((i) => askIdFromDir(i.dir) === state2.inlineDeckStartAskId);
8616
+ if (idx >= 0) startIndex = idx;
8617
+ state2.inlineDeckStartAskId = null;
8618
+ }
8620
8619
  return mountResolutionPanel(
8621
8620
  {
8622
- // Reviews are surfaced by the review-action panel, not the deck resolver —
8623
- // they have no deck.json, so buildDeck() returns null and the resolver
8624
- // would tear the whole inbox surface down the moment it advanced onto one.
8625
- // Excluding them keeps the resolver to deck-backed asks; once those drain
8626
- // it tears down and renderInboxDeckRows re-dispatches the now-front review.
8627
- aggregateInbox: state2.aggregateInbox.filter((i) => i.kind !== "review"),
8628
- startIndex: 0,
8621
+ aggregateInbox: deckQueue,
8622
+ startIndex,
8629
8623
  cols,
8630
8624
  rows,
8631
8625
  daemonSend: send,
8632
8626
  onUnmount: () => {
8633
8627
  state2.inlineDeck = null;
8634
8628
  state2.visuals.clear();
8635
- state2.focusPane = "tree";
8636
8629
  requestRender();
8637
8630
  },
8638
8631
  onOrphanTakeover: makeOrphanTakeover(state2, {
@@ -8962,13 +8955,19 @@ function startApp(state2, cleanup2) {
8962
8955
  let contextFiles = [];
8963
8956
  const listPromise = send({ type: "list", cwd: state2.cwd });
8964
8957
  const statusPromise = state2.selectedSessionId ? send({ type: "status", sessionId: state2.selectedSessionId, cwd: state2.cwd }) : null;
8965
- const inboxPromise = inboxList();
8966
- const [listRes, statusRes, aggregateInbox] = await Promise.all([
8958
+ const inboxPromise = inboxList(state2.cwd);
8959
+ const focusPromise = focusGet(state2.cwd);
8960
+ const [listRes, statusRes, aggregateInbox, focusReq] = await Promise.all([
8967
8961
  listPromise,
8968
8962
  statusPromise ?? Promise.resolve(null),
8969
- inboxPromise
8963
+ inboxPromise,
8964
+ focusPromise
8970
8965
  ]);
8971
8966
  state2.aggregateInbox = aggregateInbox;
8967
+ if (focusReq !== null && !state2.resolutionActive) {
8968
+ state2.pendingFocus = { sessionId: focusReq.sessionId, askId: focusReq.askId, attempts: 0 };
8969
+ state2.searchFilter = null;
8970
+ }
8972
8971
  const sessions = listRes.ok ? listRes.data?.sessions ?? [] : [];
8973
8972
  if (!state2.resolutionActive) {
8974
8973
  const aliveWindows = listAllWindowIds();
@@ -9169,6 +9168,10 @@ function startApp(state2, cleanup2) {
9169
9168
  const q = state2.searchFilter.toLowerCase();
9170
9169
  return s.task.toLowerCase().includes(q) || s.id.toLowerCase().includes(q);
9171
9170
  }) : state2.sessions;
9171
+ if (state2.pendingFocus) {
9172
+ state2.expanded.add("section:running");
9173
+ state2.expanded.add("section:done");
9174
+ }
9172
9175
  const statusFP = filteredSessions.map((s) => `${s.status}:${s.windowAlive}:${s.runningAgentCount}:${s.orphaned ?? false}`).join(",");
9173
9176
  const inboxFP = `${state2.aggregateInbox.length}:${state2.aggregateInbox.map((i) => askIdFromDir(i.dir)).join(",")}`;
9174
9177
  const cacheKey = `${state2.expanded.size}:${filteredSessions.length}:${state2.selectedSession?.id}:${state2.contextFiles.length}:${state2.searchFilter}:${statusFP}:${inboxFP}`;
@@ -9195,6 +9198,22 @@ function startApp(state2, cleanup2) {
9195
9198
  const onNeedsYou = cursorNode?.type === "needs-you-virtual";
9196
9199
  if (prevCursorOnNeedsYou && !onNeedsYou && state2.inlineDeck) state2.inlineDeck.unmount();
9197
9200
  prevCursorOnNeedsYou = onNeedsYou;
9201
+ if (state2.pendingFocus) {
9202
+ const { askId: askId2 } = state2.pendingFocus;
9203
+ const askPresent = state2.aggregateInbox.some((i) => askIdFromDir(i.dir) === askId2);
9204
+ const nyIdx = nodes.findIndex((n) => n.type === "needs-you-virtual");
9205
+ if (askPresent && nyIdx >= 0) {
9206
+ state2.cursorIndex = nyIdx;
9207
+ state2.cursorNodeId = nodes[nyIdx].id;
9208
+ state2.inlineDeckStartAskId = askId2;
9209
+ state2.focusPane = "detail";
9210
+ state2.pendingFocus = null;
9211
+ requestRender();
9212
+ } else {
9213
+ state2.pendingFocus.attempts++;
9214
+ if (state2.pendingFocus.attempts > 25) state2.pendingFocus = null;
9215
+ }
9216
+ }
9198
9217
  const rawSessionId = cursorNode?.sessionId;
9199
9218
  const newSessionId = rawSessionId ? rawSessionId : null;
9200
9219
  if (newSessionId !== state2.selectedSessionId) {