@warren-dean/code-kit 0.12.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,6 +21,7 @@ import {
21
21
  answerPane,
22
22
  approvalAsidePrompt,
23
23
  approvePane,
24
+ questionAsidePrompt,
24
25
  commandItself,
25
26
  band,
26
27
  bandLines,
@@ -50,8 +51,12 @@ import {
50
51
  toolLine,
51
52
  } from './panes.mjs';
52
53
  import {
54
+ answeredPrompt,
55
+ designHandedOver,
53
56
  leadPrompt,
54
57
  nudgeText,
58
+ outcomePrompt,
59
+ readOutcome,
55
60
  restartPrompt,
56
61
  stallDue,
57
62
  stepKey,
@@ -60,7 +65,7 @@ import {
60
65
  withoutInFlight,
61
66
  } from './loop.mjs';
62
67
  import { goalOf, nextText } from './mission.mjs';
63
- import { contextOf, decisionCards, limitsWhy } from './decisions.mjs';
68
+ import { contextOf, decisionCards, limitsWhy, nativeChoice } from './decisions.mjs';
64
69
  import { DEFAULT_TEAM_UI, TEAM_ID, teamFrame } from './teamview.mjs';
65
70
  import { glowOf, mapStoryOf, touchOf } from './codemap.mjs';
66
71
  import { harnessSettings } from '../lib/harness.mjs';
@@ -110,7 +115,22 @@ const loop = {
110
115
  nextStep: null, // what `code-kit next` last said, for the band's next row
111
116
  done: null, // { count } once every story is done
112
117
  doneDismissed: false,
118
+ // LOOP-11: the step the lead's turn was prompted with, until that turn ends: { prompt, key, label, kind, target }
119
+ sent: null,
120
+ // LOOP-11, LOOP-12: what holds the loop: { kind: 'person' | 'stopped' | 'blocked' | 'design', key, label, prompt, question, options, reason }
121
+ waiting: null,
122
+ handedDesign: new Set(), // LOOP-12: design stories handed to the lead this session
113
123
  };
124
+ // LOOP-10: whether this session runs the loop, the session that does when another does, and when this
125
+ // one last renewed its lease.
126
+ const lease = { mine: false, other: null, renewed: -Infinity };
127
+ const LEASE_RENEW_MS = 30000;
128
+ // LOOP-11: the lead's last reply, read once before the loop next prompts it: { turnId, text, aborted, sent, read }
129
+ let lastReply = null;
130
+ let turnQuestions = []; // the open questions as the lead's running turn started
131
+ // DECIDE-4: decisions asked in Claude Code's own question dialog this session, and whether one is open.
132
+ const offered = new Set();
133
+ let offering = false;
114
134
  /** LOOP-8: the times in a row the lead is prompted with one step before the person is asked. */
115
135
  const ASK_LIMIT = 2;
116
136
  let leadTurn = null; // the lead's running turn, if any
@@ -206,7 +226,7 @@ export function register(on) {
206
226
  const cli = `${$.plugin.root}/bin/code-kit.mjs`;
207
227
  const cwd = e.cwd ?? (await $.session.cwd());
208
228
  const session = await $.session.id();
209
- where = { cli, cwd, interactive: e.isInteractive !== false };
229
+ where = { cli, cwd, session, interactive: e.isInteractive !== false };
210
230
  // The held call's answer, for the `code-kit hold` it waits in.
211
231
  const answerHeld = (id, answer) =>
212
232
  $.process.run(['node', cli, 'hold', id, '--answer', answer], { cwd });
@@ -216,9 +236,12 @@ export function register(on) {
216
236
  };
217
237
  // What a CLI run said, in a line: why it refused, for a notice.
218
238
  const firstLine = (ran) => (ran.stderr || ran.stdout).trim().split('\n')[0];
219
- // A session starts with the loop running: a pause recorded by an earlier one no longer holds.
220
- if ((await json('loop').catch(() => null))?.paused)
239
+ // A session starts with the loop running: a pause recorded by an earlier one no longer holds. A pause
240
+ // by a session that still runs the loop (LOOP-10) is that session's, and stays.
241
+ const recorded = await json('loop').catch(() => null);
242
+ if (recorded?.paused && (!recorded.lead || recorded.lead.session === session))
221
243
  await $.process.run(['node', cli, 'loop', 'resume'], { cwd }).catch(() => {});
244
+ lease.other = recorded?.lead && recorded.lead.session !== session ? recorded.lead : null;
222
245
  const stamp = async (path) => {
223
246
  const s = await $.fs.stat(`${cwd}/${path}`).catch(() => null);
224
247
  return s ? `${path}@${s.mtimeMs}` : `${path}-`;
@@ -269,6 +292,11 @@ export function register(on) {
269
292
  boardSynced = at;
270
293
  $.process.run(['node', cli, 'team', 'board'], { cwd, timeoutMs: 120000 }).catch(() => {});
271
294
  }
295
+ // LOOP-10: while another session runs the loop, whether it still does (its lease lapses as it ends).
296
+ if (!lease.mine) {
297
+ const lead = (await json('loop').catch(() => null))?.lead ?? null;
298
+ lease.other = lead && lead.session !== session ? lead : null;
299
+ }
272
300
  // The agents at work; none where the session has none bound (claude -p before it mounts).
273
301
  const atWork = agentsAtWork(await $.agent.list().catch(() => []));
274
302
  model = {
@@ -564,12 +592,24 @@ export function register(on) {
564
592
  answerWith: (line) => act.sendAnswer({ kind: 'question', ...line.question }, line.choice),
565
593
  // ASK-2, ASK-4: Answer… opens a box for the person's own words.
566
594
  answer: async (line) => {
567
- answering = { kind: 'question', ...line.question, text: '', error: null };
595
+ answering = {
596
+ kind: 'question',
597
+ ...line.question,
598
+ text: '',
599
+ error: null,
600
+ aside: { thread: [], draft: '' },
601
+ };
568
602
  await $.ui.open({ id: ANSWER_ID, title: 'Answer', focus: true, closeOnEscape: true });
569
603
  $.ui.invalidate('ui.render');
570
604
  },
571
605
  answerBrief: async (line) => {
572
- answering = { kind: 'brief', ...line.brief, text: '', error: null };
606
+ answering = {
607
+ kind: 'brief',
608
+ ...line.brief,
609
+ text: '',
610
+ error: null,
611
+ aside: { thread: [], draft: '' },
612
+ };
573
613
  await $.ui.open({ id: ANSWER_ID, title: 'Answer', focus: true, closeOnEscape: true });
574
614
  $.ui.invalidate('ui.render');
575
615
  },
@@ -613,17 +653,32 @@ export function register(on) {
613
653
  }
614
654
  }
615
655
  notice = null;
616
- const said = /[.!?]$/.test(text) ? text : `${text}.`;
617
- await $.prompt.submit({
618
- text:
619
- q.kind === 'question'
620
- ? `The person answered ${q.id} (${q.question}): ${said}${q.blocks ? ` ${q.story} no longer waits on them.` : ''} Carry on with it.`
621
- : `The person answered an open question in ${q.path}, "${q.question}": ${said} Record it in the brief's decision log, take it out of the open questions, and carry on with it.`,
622
- });
656
+ // Answered, the decision goes at once; the lead hears it as a turn of its own once it's idle, which
657
+ // can be long after (so the prompt isn't waited on).
623
658
  if (q.kind === 'brief') {
624
659
  briefLater.add(q.question);
625
660
  model = { ...model, brief: (model.brief ?? []).filter((b) => b.question !== q.question) };
626
661
  }
662
+ if (q.kind === 'question')
663
+ model = { ...model, questions: (model.questions ?? []).filter((x) => x.id !== q.id) };
664
+ // LOOP-11: answered, what the lead waited on no longer holds the loop.
665
+ if (
666
+ q.kind === 'lead' ||
667
+ (q.kind === 'question' && loop.waiting?.questionIds?.includes(q.id))
668
+ )
669
+ loop.waiting = null;
670
+ $.ui.invalidate('ui.render');
671
+ const said = /[.!?]$/.test(text) ? text : `${text}.`;
672
+ $.prompt
673
+ .submit({
674
+ text:
675
+ q.kind === 'question'
676
+ ? `The person answered ${q.id} (${q.question}): ${said}${q.blocks ? ` ${q.story} no longer waits on them.` : ''} Carry on with it.`
677
+ : q.kind === 'lead'
678
+ ? answeredPrompt(q.question, text)
679
+ : `The person answered an open question in ${q.path}, "${q.question}": ${said} Record it in the brief's decision log, take it out of the open questions, and carry on with it.`,
680
+ })
681
+ .catch(() => {});
627
682
  await act.reload();
628
683
  return true;
629
684
  },
@@ -634,16 +689,20 @@ export function register(on) {
634
689
  deniedDone: async (line) => {
635
690
  denied.delete(line.denial.id);
636
691
  $.ui.invalidate('ui.render');
637
- await $.prompt.submit({
638
- text: `The person ran it themselves, or allowed it: ${line.denial.command ?? line.denial.what} (Claude Code had refused it for ${line.denial.who}). Check it worked, and carry on.`,
639
- });
692
+ $.prompt
693
+ .submit({
694
+ text: `The person ran it themselves, or allowed it: ${line.denial.command ?? line.denial.what} (Claude Code had refused it for ${line.denial.who}). Check it worked, and carry on.`,
695
+ })
696
+ .catch(() => {});
640
697
  },
641
698
  deniedNo: async (line) => {
642
699
  denied.delete(line.denial.id);
643
700
  $.ui.invalidate('ui.render');
644
- await $.prompt.submit({
645
- text: `The person won't run ${line.denial.command ?? line.denial.what} now (Claude Code refused it for ${line.denial.who}). Don't try to get around it: carry on with other work, and if a story can't go on without it, ask with code-kit ask --blocks.`,
646
- });
701
+ $.prompt
702
+ .submit({
703
+ text: `The person won't run ${line.denial.command ?? line.denial.what} now (Claude Code refused it for ${line.denial.who}). Don't try to get around it: carry on with other work, and if a story can't go on without it, ask with code-kit ask --blocks.`,
704
+ })
705
+ .catch(() => {});
647
706
  },
648
707
  // REPORT-3: the person sends a drafted report to code-kit's GitHub, or discards it.
649
708
  sendReport: async (line) => {
@@ -675,6 +734,40 @@ export function register(on) {
675
734
  heldId: line.held.id,
676
735
  outside: line.held.outside ?? null,
677
736
  }),
737
+ // ACT-5: a side call over the lead's own conversation (it shares the prompt cache, and nothing is
738
+ // added to the conversation); before the lead's first reply, from the prompt's facts alone.
739
+ sideCall: async (prompt) => {
740
+ const failed = (err) => ({ isAnswered: false, reason: String(err?.message ?? err) });
741
+ const reply = await $.model.fork({ prompt }).catch(failed);
742
+ if (reply.isAnswered || reply.reason !== 'nothing-to-fork') return reply;
743
+ return $.model.complete({ model: 'sonnet', prompt, maxTokens: 800 }).catch(failed);
744
+ },
745
+ // ASK-5: a question about a question, asked in the answer box before answering.
746
+ askAnswer: async (question) => {
747
+ const q = question.trim();
748
+ if (!answering || !q) return;
749
+ const asked = answering;
750
+ const turn = { question: q, pending: true };
751
+ const thread = asked.aside.thread;
752
+ answering = { ...asked, aside: { thread: [...thread, turn], draft: '' } };
753
+ $.ui.invalidate('ui.render');
754
+ const reply = await act.sideCall(questionAsidePrompt({ q: asked, thread, question: q }));
755
+ if (!answering || answering.question !== asked.question) return;
756
+ const done = reply.isAnswered
757
+ ? { question: q, answer: reply.text }
758
+ : {
759
+ question: q,
760
+ error: `No answer: ${reply.reason}${reply.status ? ` (${reply.status})` : ''}`,
761
+ };
762
+ answering = {
763
+ ...answering,
764
+ aside: {
765
+ ...answering.aside,
766
+ thread: answering.aside.thread.map((t) => (t === turn ? done : t)),
767
+ },
768
+ };
769
+ $.ui.invalidate('ui.render');
770
+ },
678
771
  // ACT-5: a question about the approval, answered in its pane by a side call over the lead's own
679
772
  // conversation (it shares the prompt cache, and nothing is added to the conversation); before the
680
773
  // lead's first reply, from code-kit's facts alone.
@@ -694,13 +787,7 @@ export function register(on) {
694
787
  thread,
695
788
  question: q,
696
789
  });
697
- let reply = await $.model
698
- .fork({ prompt })
699
- .catch((err) => ({ isAnswered: false, reason: String(err?.message ?? err) }));
700
- if (!reply.isAnswered && reply.reason === 'nothing-to-fork')
701
- reply = await $.model
702
- .complete({ model: 'sonnet', prompt, maxTokens: 800 })
703
- .catch((err) => ({ isAnswered: false, reason: String(err?.message ?? err) }));
790
+ const reply = await act.sideCall(prompt);
704
791
  // Answered for this approval only: a pane closed or moved on meanwhile keeps what it has.
705
792
  if (!approving || approving.request !== asked.request) return;
706
793
  const done = reply.isAnswered
@@ -952,17 +1039,184 @@ export function register(on) {
952
1039
  loop.steps.push({ at: await $.clock.now(), kind, target, ...(prompt ? { prompt } : {}) });
953
1040
  if (loop.steps.length > 50) loop.steps.shift();
954
1041
  },
955
- // LOOP-1: a prompt to the lead, recorded. It isn't awaited: the turn it starts runs on.
956
- submit: async (prompt, kind, target) => {
1042
+ // LOOP-1: a prompt to the lead, recorded. It isn't awaited: the turn it starts runs on. LOOP-11:
1043
+ // the step is kept until that turn ends, so the loop reads the reply to it.
1044
+ submit: async (
1045
+ prompt,
1046
+ kind,
1047
+ target,
1048
+ { key = `${kind}:${target}`, label = `${kind} ${target}` } = {},
1049
+ ) => {
957
1050
  loop.started = true;
958
1051
  loop.proposal = null;
1052
+ loop.sent = { prompt, key, label, kind, target };
1053
+ // LOOP-12: a design story is handed to the lead once; the person designs it with the lead.
1054
+ if (kind === 'design')
1055
+ for (const id of String(target).split(/\s+/).filter(Boolean)) loop.handedDesign.add(id);
959
1056
  await act.record(kind, target, prompt);
960
1057
  $.ui.invalidate('ui.render');
961
1058
  $.prompt.submit({ text: prompt }).catch((err) => {
1059
+ loop.sent = null;
962
1060
  notice = `The loop couldn't prompt the lead: ${err?.message ?? err}`;
963
1061
  $.ui.invalidate('ui.render');
964
1062
  });
965
1063
  },
1064
+ // LOOP-10: this session takes or renews the lease of the one session that runs the loop; another
1065
+ // session holding it keeps it, unless the person moves it here (`take`).
1066
+ lead: async ({ take = false } = {}) => {
1067
+ const now = await $.clock.now();
1068
+ if (lease.mine && !take && now - lease.renewed < LEASE_RENEW_MS) return true;
1069
+ const ran = await $.process
1070
+ .run(['node', cli, 'loop', 'lead', '--session', session, ...(take ? ['--take'] : [])], {
1071
+ cwd,
1072
+ })
1073
+ .catch(() => ({ exitCode: 1 }));
1074
+ const was = lease.mine;
1075
+ lease.mine = ran.exitCode === 0;
1076
+ lease.renewed = now;
1077
+ lease.other = lease.mine ? null : ((await json('loop').catch(() => null))?.lead ?? null);
1078
+ // Taken over by another session: this one stops prompting its lead, and offers nothing more.
1079
+ if (was && !lease.mine) {
1080
+ loop.proposal = null;
1081
+ loop.pending = [];
1082
+ loop.waiting = null;
1083
+ }
1084
+ if (was !== lease.mine || !lease.mine) $.ui.invalidate('ui.render');
1085
+ return lease.mine;
1086
+ },
1087
+ // LOOP-10: the person moves the loop to this session; the other stops prompting its lead.
1088
+ leadHere: async () => {
1089
+ if (!(await act.lead({ take: true }))) {
1090
+ notice = "This session couldn't take the loop: code-kit loop lead failed.";
1091
+ $.ui.invalidate('ui.render');
1092
+ return;
1093
+ }
1094
+ loop.lastKey = null;
1095
+ await act.record('lead', 'this session');
1096
+ $.ui.invalidate('ui.render');
1097
+ await act.step('resume');
1098
+ },
1099
+ // LOOP-11: the lead's last reply, read once before the loop prompts it again. A reply that waits on
1100
+ // the person, or a turn they stopped, holds the loop until they speak; a step the lead didn't take
1101
+ // holds that step while it's still next.
1102
+ hear: async () => {
1103
+ const reply = lastReply;
1104
+ if (!reply || reply.read) return;
1105
+ reply.read = true;
1106
+ const sent = reply.sent;
1107
+ const step = sent
1108
+ ? {
1109
+ key: sent.key,
1110
+ label: sent.label,
1111
+ prompt: sent.prompt,
1112
+ stepKind: sent.kind,
1113
+ target: sent.target,
1114
+ }
1115
+ : {};
1116
+ if (reply.aborted) {
1117
+ loop.waiting = { kind: 'stopped', ...step };
1118
+ $.ui.invalidate('ui.render');
1119
+ return;
1120
+ }
1121
+ // It asked through code-kit: those questions are the person's decisions already.
1122
+ const open = ((await json('ask').catch(() => null)) ?? []).filter(
1123
+ (q) => q.state === 'open',
1124
+ );
1125
+ const asked = open.filter((q) => !reply.asked.includes(q.id));
1126
+ if (asked.length) {
1127
+ model = { ...model, questions: open };
1128
+ loop.waiting = {
1129
+ kind: 'person',
1130
+ ...step,
1131
+ questionIds: asked.map((q) => q.id),
1132
+ reason: `it asked you ${asked.map((q) => q.id).join(', ')} through code-kit`,
1133
+ };
1134
+ $.ui.invalidate('ui.render');
1135
+ return;
1136
+ }
1137
+ if (!reply.text.trim()) return;
1138
+ const said = await act.sideCall(
1139
+ outcomePrompt({ prompt: sent?.prompt ?? null, answer: reply.text }),
1140
+ );
1141
+ const heard = said.isAnswered ? readOutcome(said.text) : null;
1142
+ // Spec 05's data: what came of the step, once known.
1143
+ const record = sent ? loop.steps.findLast((x) => x.prompt === sent.prompt) : null;
1144
+ if (record) record.outcome = heard?.outcome ?? 'unread';
1145
+ if (heard?.outcome === 'needs-person')
1146
+ loop.waiting = {
1147
+ kind: 'person',
1148
+ ...step,
1149
+ question: heard.question,
1150
+ options: heard.options,
1151
+ reason: heard.reason,
1152
+ };
1153
+ else if (heard?.outcome === 'blocked' && sent)
1154
+ loop.waiting = { kind: 'blocked', ...step, reason: heard.reason };
1155
+ if (loop.waiting) $.ui.invalidate('ui.render');
1156
+ },
1157
+ // LOOP-11: the person's choice from the lead's options, or their own words, told to the lead.
1158
+ answerLead: (line) =>
1159
+ act.sendAnswer({ kind: 'lead', question: line.waiting.question }, line.choice),
1160
+ answerLeadTyped: (line, text) =>
1161
+ act.sendAnswer({ kind: 'lead', question: line.waiting.question }, text),
1162
+ answerLeadText: async (line) => {
1163
+ answering = {
1164
+ kind: 'lead',
1165
+ question: line.waiting.question,
1166
+ options: line.waiting.options ?? [],
1167
+ text: '',
1168
+ error: null,
1169
+ aside: { thread: [], draft: '' },
1170
+ };
1171
+ await $.ui.open({ id: ANSWER_ID, title: 'Answer', focus: true, closeOnEscape: true });
1172
+ $.ui.invalidate('ui.render');
1173
+ },
1174
+ // DECIDE-4: a question's answer typed in the dialog, as the person's own words.
1175
+ answerTyped: (line, text) => act.sendAnswer({ kind: 'question', ...line.question }, text),
1176
+ // LOOP-11, LOOP-12: the person asks the lead again for the step that held the loop.
1177
+ askAgain: async () => {
1178
+ const w = loop.waiting;
1179
+ if (!w?.prompt || leadTurn) return;
1180
+ loop.waiting = null;
1181
+ loop.asked = { key: w.key, times: 0 };
1182
+ await act.submit(w.prompt, w.stepKind, w.target, { key: w.key, label: w.label });
1183
+ },
1184
+ // LOOP-11: the person lets the loop go on.
1185
+ carryOn: async () => {
1186
+ loop.waiting = null;
1187
+ loop.lastKey = null;
1188
+ $.ui.invalidate('ui.render');
1189
+ await act.step('resume');
1190
+ },
1191
+ // DECIDE-4: the next decision the person settles with a choice, asked in Claude Code's own question
1192
+ // dialog while the lead is idle. Each is asked once: dismissed, it stays in the band and the
1193
+ // Decisions tab. Never in a session another one runs the loop for, nor over what the person types.
1194
+ offer: async () => {
1195
+ if (offering || leadTurn || loop.sent || !where.interactive) return;
1196
+ if (lease.other && !lease.mine) return;
1197
+ const now = await $.clock.now();
1198
+ const choice = cardsNow(now)
1199
+ .map((card) => ({ card, choice: nativeChoice(card) }))
1200
+ .find((c) => c.choice && !offered.has(c.choice.key));
1201
+ if (!choice) return;
1202
+ const box = await $.prompt.read().catch(() => null);
1203
+ if (box?.text?.trim()) return;
1204
+ offered.add(choice.choice.key);
1205
+ offering = true;
1206
+ const { question, header, options, typed } = choice.choice;
1207
+ const answer = await $.ui
1208
+ .ask(question, { options: options.map((o) => o.label), header })
1209
+ .catch(() => null)
1210
+ .finally(() => {
1211
+ offering = false;
1212
+ });
1213
+ if (answer == null) return;
1214
+ const picked = options.find((o) => o.label === answer);
1215
+ if (picked?.act) await act[picked.act](picked.line);
1216
+ else if (!picked && typed && answer.trim())
1217
+ await act[typed](choice.card.line, answer.trim());
1218
+ act.offer().catch(() => {});
1219
+ },
966
1220
  // LOOP-1, LOOP-5, LOOP-7: with the lead idle and no draft, the step `next` reports.
967
1221
  step: async (trigger) => {
968
1222
  const h = harness();
@@ -971,6 +1225,11 @@ export function register(on) {
971
1225
  if (trigger === 'tick' && !loop.started && !loop.pending.length) return;
972
1226
  const box = await $.prompt.read().catch(() => null);
973
1227
  if (box?.text?.trim()) return;
1228
+ // LOOP-10: one session in the project runs the loop; in any other, the lead is the person's alone.
1229
+ if (!(await act.lead())) return;
1230
+ // LOOP-11: the lead's last reply first: one that waits on the person holds every step.
1231
+ await act.hear();
1232
+ if (loop.waiting && ['person', 'stopped'].includes(loop.waiting.kind)) return;
974
1233
  if (loop.pending.length) {
975
1234
  const { story, prompt } = loop.pending.shift();
976
1235
  // Until the person has chosen how hands-off the loop is, or under propose, a restart is offered
@@ -982,6 +1241,7 @@ export function register(on) {
982
1241
  label: `Restart ${story}`,
983
1242
  kind: 'restart',
984
1243
  target: story,
1244
+ key: `restart:${story}`,
985
1245
  first: !chosenYet,
986
1246
  };
987
1247
  $.ui.invalidate('ui.render');
@@ -1028,7 +1288,19 @@ export function register(on) {
1028
1288
  loop.asked = { key, times: 0 };
1029
1289
  if (loop.proposal?.repeated) loop.proposal = null;
1030
1290
  }
1291
+ // LOOP-11, LOOP-12: a step the lead didn't take, or designs left to the person and the lead, hold
1292
+ // that step while it's still next; any other step goes on.
1293
+ if (loop.waiting) {
1294
+ if (loop.waiting.key === key) return;
1295
+ loop.waiting = null;
1296
+ $.ui.invalidate('ui.render');
1297
+ }
1031
1298
  if (!prompt) return;
1299
+ if (designHandedOver(step, loop.handedDesign)) {
1300
+ loop.waiting = { kind: 'design', key, label, prompt, stepKind: kind, target };
1301
+ $.ui.invalidate('ui.render');
1302
+ return;
1303
+ }
1032
1304
  // Never the same step twice while the project stands still.
1033
1305
  const print = await act.fingerprint();
1034
1306
  if (key === loop.lastKey && print === loop.lastPrint) return;
@@ -1038,7 +1310,7 @@ export function register(on) {
1038
1310
  // moving elsewhere (lanes committing) doesn't make a third prompt useful. The person decides.
1039
1311
  if (loop.asked.times >= ASK_LIMIT) {
1040
1312
  if (loop.proposal?.label !== label) {
1041
- loop.proposal = { prompt, label, kind, target, repeated: loop.asked.times };
1313
+ loop.proposal = { prompt, label, kind, target, key, repeated: loop.asked.times };
1042
1314
  $.ui.invalidate('ui.render');
1043
1315
  }
1044
1316
  return;
@@ -1046,12 +1318,12 @@ export function register(on) {
1046
1318
  // Until the person has chosen how hands-off the loop is, its first step is offered, not taken.
1047
1319
  const chosen = (model.check?.harnessSet ?? []).includes('autonomy');
1048
1320
  if (h.autonomy === 'propose' || !chosen) {
1049
- loop.proposal = { prompt, label, kind, target, first: !chosen };
1321
+ loop.proposal = { prompt, label, kind, target, key, first: !chosen };
1050
1322
  $.ui.invalidate('ui.render');
1051
1323
  return;
1052
1324
  }
1053
1325
  loop.asked.times += 1;
1054
- await act.submit(prompt, kind, target);
1326
+ await act.submit(prompt, kind, target, { key, label });
1055
1327
  },
1056
1328
  // The first offer's answers: the person's choice of autonomy, recorded as theirs, then acted on.
1057
1329
  chooseAutonomy: async (value) => {
@@ -1094,10 +1366,10 @@ export function register(on) {
1094
1366
  },
1095
1367
  go: async () => {
1096
1368
  if (!loop.proposal || leadTurn) return;
1097
- const { prompt, kind, target, repeated } = loop.proposal;
1369
+ const { prompt, kind, target, key, label, repeated } = loop.proposal;
1098
1370
  // The person asked again: once more, then it's theirs to offer again if it still doesn't take.
1099
1371
  loop.asked.times = repeated ? ASK_LIMIT : loop.asked.times + 1;
1100
- await act.submit(prompt, kind, target);
1372
+ await act.submit(prompt, kind, target, { key, label });
1101
1373
  },
1102
1374
  // LOOP-6: pausing stops new steps at once; a turn in progress finishes.
1103
1375
  pause: async () => {
@@ -1353,6 +1625,9 @@ export function register(on) {
1353
1625
  if (busy) return;
1354
1626
  busy = true;
1355
1627
  try {
1628
+ // LOOP-10: the lead session renews its lease; DECIDE-4: with the lead idle, a decision waiting.
1629
+ if (lease.mine) await act.lead();
1630
+ if (!leadTurn) act.offer().catch(() => {});
1356
1631
  await act.stalls();
1357
1632
  await act.animate();
1358
1633
  const now = await act.fingerprint();
@@ -1577,6 +1852,8 @@ export function register(on) {
1577
1852
 
1578
1853
  on('turn.start', async ($, e, next) => {
1579
1854
  leadTurn = e.turnId;
1855
+ // LOOP-11: the questions already open, so the loop knows which the lead asked in this turn.
1856
+ turnQuestions = (model.questions ?? []).map((q) => q.id);
1580
1857
  $.ui.invalidate('ui.render'); // the lead's lane reads working while its turn runs
1581
1858
  if (reviewTurn === 'next') reviewTurn = e.turnId;
1582
1859
  return next(e);
@@ -1598,6 +1875,16 @@ export function register(on) {
1598
1875
  // as the spike found it must be). The prompt it submits isn't awaited: that turn isn't this hook's.
1599
1876
  if (!e.agentId && (!leadTurn || leadTurn === e.turnId)) {
1600
1877
  leadTurn = null;
1878
+ // LOOP-11: the lead's reply, read before the loop next prompts it, with the step it answered.
1879
+ lastReply = {
1880
+ turnId: e.turnId,
1881
+ text: e.answer ?? '',
1882
+ aborted: Boolean(e.isAborted),
1883
+ sent: loop.sent,
1884
+ asked: turnQuestions,
1885
+ read: false,
1886
+ };
1887
+ loop.sent = null;
1601
1888
  $.ui.invalidate('ui.render');
1602
1889
  // What the person asked for goes first, whatever the loop's state.
1603
1890
  if (personAsks.length) {
@@ -1606,15 +1893,47 @@ export function register(on) {
1606
1893
  return res;
1607
1894
  }
1608
1895
  await act?.step('turn').catch(() => {});
1896
+ // DECIDE-4: a decision waiting, asked in Claude Code's own dialog; not awaited, the person answers.
1897
+ act?.offer().catch(() => {});
1609
1898
  }
1610
1899
  return res;
1611
1900
  });
1612
1901
 
1902
+ // LOOP-11: the person speaking to the lead answers what the loop waited on, whatever they said; the
1903
+ // loop reads the lead's reply to them before it prompts again. Designs stay theirs (LOOP-12).
1904
+ on('prompt.submit', async ($, e, next) => {
1905
+ if (['composer', 'bridge'].includes(e.origin?.kind) && loop.waiting?.kind !== 'design') {
1906
+ if (loop.waiting) $.ui.invalidate('ui.render');
1907
+ loop.waiting = null;
1908
+ loop.asked = { key: null, times: 0 };
1909
+ }
1910
+ return next(e);
1911
+ });
1912
+
1913
+ // LOOP-10: the lead session ends: the next session to act takes the loop at once.
1914
+ on('session.end', async ($, e, next) => {
1915
+ if (lease.mine && where) {
1916
+ lease.mine = false;
1917
+ await $.process
1918
+ .run(['node', where.cli, 'loop', 'release', '--session', where.session], { cwd: where.cwd })
1919
+ .catch(() => {});
1920
+ }
1921
+ return next(e);
1922
+ });
1923
+
1613
1924
  // What the pane and band show of the loop.
1614
1925
  const loopNow = (now) => {
1615
1926
  const h = harness();
1616
1927
  const state =
1617
- h.autonomy === 'off' ? 'off' : loop.done ? 'done' : loop.paused ? 'paused' : 'running';
1928
+ h.autonomy === 'off'
1929
+ ? 'off'
1930
+ : lease.other && !lease.mine
1931
+ ? 'elsewhere'
1932
+ : loop.done
1933
+ ? 'done'
1934
+ : loop.paused
1935
+ ? 'paused'
1936
+ : 'running';
1618
1937
  const laneNotes = {};
1619
1938
  for (const a of activity.values()) {
1620
1939
  if (!a.lane) continue;
@@ -1632,6 +1951,7 @@ export function register(on) {
1632
1951
  started: loop.started,
1633
1952
  last: last ? `${last.kind} ${last.target}` : null,
1634
1953
  proposal: loop.proposal,
1954
+ waiting: loop.waiting,
1635
1955
  flagged: [...flagged.values()],
1636
1956
  doneCount: loop.done?.count ?? 0,
1637
1957
  steps: loop.steps,
@@ -1768,6 +2088,7 @@ export function register(on) {
1768
2088
  flag: (t) => `${t} stalled again after its restarts, so it came to you.`,
1769
2089
  pause: () => 'You paused the loop.',
1770
2090
  resume: () => 'You resumed the loop.',
2091
+ lead: () => 'You moved the loop to this session.',
1771
2092
  done: (t) => `Every story was done (${t}).`,
1772
2093
  };
1773
2094
  const journalNow = () => {
@@ -1777,7 +2098,7 @@ export function register(on) {
1777
2098
  .map((x) => ({
1778
2099
  at: x.at,
1779
2100
  kind: 'loop',
1780
- who: ['pause', 'resume'].includes(x.kind) ? 'person' : 'lead',
2101
+ who: ['pause', 'resume', 'lead'].includes(x.kind) ? 'person' : 'lead',
1781
2102
  text: STEP_WORDS[x.kind](x.target),
1782
2103
  }));
1783
2104
  return [...recorded, ...steps].sort((a, b) => a.at - b.at);
@@ -2004,6 +2325,11 @@ export function register(on) {
2004
2325
  },
2005
2326
  onSubmit: (value) => act.confirmAnswer(value),
2006
2327
  onCancel: () => act.cancelAnswer(),
2328
+ onAskInput: (value) => {
2329
+ answering = { ...answering, aside: { ...answering.aside, draft: value } };
2330
+ $.ui.invalidate('ui.render');
2331
+ },
2332
+ onAsk: (value) => act.askAnswer(value),
2007
2333
  });
2008
2334
  });
2009
2335