@warren-dean/code-kit 0.13.0 → 0.15.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.
@@ -148,3 +148,94 @@ export function restartPrompt({ type, id, story, minutes }) {
148
148
  /** The message a quiet agent gets (LOOP-3). */
149
149
  export const nudgeText = (minutes) =>
150
150
  `No tool call for ${minutes} minutes: report where you are, or carry on.`;
151
+
152
+ /** LOOP-11: what a lead turn left the build at, as the loop reads the lead's reply. */
153
+ export const OUTCOMES = new Set(['done', 'started', 'needs-person', 'blocked']);
154
+ const ANSWER_SHOWN = 4000;
155
+
156
+ /**
157
+ * LOOP-11: the side call that reads the lead's last reply before the loop prompts it again: over the
158
+ * lead's own conversation (so it knows the whole exchange), with the reply itself for a call that
159
+ * can't fork. `prompt` is the loop's own prompt for that turn, or null when the person started it.
160
+ */
161
+ export function outcomePrompt({ prompt = null, answer = '' }) {
162
+ const reply = String(answer ?? '').trim();
163
+ const shown =
164
+ reply.length > ANSWER_SHOWN ? `${reply.slice(0, ANSWER_SHOWN)}\n[… cut short]` : reply;
165
+ return [
166
+ prompt
167
+ ? `code-kit's lead loop prompted you: "${prompt}"`
168
+ : "The person prompted you, and code-kit's lead loop is about to prompt you with the build's next step.",
169
+ `Your reply ended your turn. It read:\n"""\n${shown}\n"""`,
170
+ 'Say where that reply left things, so the loop knows whether to prompt you again. Answer with one JSON object and nothing else:',
171
+ '{"outcome": "done" | "started" | "needs-person" | "blocked", "question": "…", "options": [{"label": "…", "effect": "…"}], "reason": "…"}',
172
+ '- done: you finished what was asked, or there was nothing to do.',
173
+ '- started: you started work that carries on without you, such as lane agents.',
174
+ "- needs-person: you asked the person something, or wait on their answer, approval or feedback before you go on. question: what you wait on, as one question; options: 2 to 4 answers they could give (a label of at most five words, and what it does), or [] when it's theirs to put in words.",
175
+ "- blocked: you didn't take the step, for a reason no answer from the person settles now. reason: why, in a sentence.",
176
+ ].join('\n');
177
+ }
178
+
179
+ /**
180
+ * LOOP-11: the side call's answer: { outcome, question, options: [{ label, effect }], reason }, or
181
+ * null when it isn't one (the loop then goes on as before).
182
+ */
183
+ export function readOutcome(text) {
184
+ const raw = String(text ?? '');
185
+ const json = raw.slice(raw.indexOf('{'), raw.lastIndexOf('}') + 1);
186
+ let parsed;
187
+ try {
188
+ parsed = JSON.parse(json);
189
+ } catch {
190
+ return null;
191
+ }
192
+ if (!parsed || !OUTCOMES.has(parsed.outcome)) return null;
193
+ const words = (s, n) =>
194
+ typeof s === 'string' && s.trim() ? s.replace(/\s+/g, ' ').trim().slice(0, n) : null;
195
+ const options = (Array.isArray(parsed.options) ? parsed.options : [])
196
+ .map((o) => ({ label: words(o?.label, 40), effect: words(o?.effect, 200) }))
197
+ .filter((o) => o.label)
198
+ .slice(0, 4);
199
+ return {
200
+ outcome: parsed.outcome,
201
+ question: words(parsed.question, 400),
202
+ options: options.length >= 2 ? options : [],
203
+ reason: words(parsed.reason, 300),
204
+ };
205
+ }
206
+
207
+ /**
208
+ * LOOP-12: a design is made with the person, so the loop hands a design story to the lead once; while
209
+ * the stories `next` names have all been handed over, it leaves them to the person and the lead.
210
+ */
211
+ export const designHandedOver = (step, handed) =>
212
+ step?.step === 'design' &&
213
+ (step.args ?? '')
214
+ .split(/\s+/)
215
+ .filter(Boolean)
216
+ .every((id) => handed.has(id));
217
+
218
+ /** LOOP-11: the lead's prompt with the person's answer to what it waited on. */
219
+ export const answeredPrompt = (question, answer) => {
220
+ const said = /[.!?]$/.test(answer) ? answer : `${answer}.`;
221
+ return question
222
+ ? `The person answered what you asked ("${question}"): ${said} Carry on with it.`
223
+ : `The person says: ${said} Carry on with it.`;
224
+ };
225
+
226
+ /** SET-4: the `model:` an agent file's frontmatter names, or null. */
227
+ export function agentFileModel(text) {
228
+ const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(text ?? ''))?.[1] ?? '';
229
+ return /^model:\s*['"]?([^'"\s]+)['"]?\s*$/m.exec(front)?.[1] ?? null;
230
+ }
231
+
232
+ /**
233
+ * SET-4: the model to start a lane agent on, or null to leave the spawn as it is: a model the lead's
234
+ * call names wins, then the lane agent file's own (other than `inherit`), then `harness.agents.lanes.model`.
235
+ */
236
+ export function laneModel({ type, model = null, lanes = {}, setting = null, fileModel = null }) {
237
+ if (model || !setting) return null;
238
+ if (!Object.values(lanes).some((l) => l.agent === type)) return null;
239
+ if (fileModel && fileModel !== 'inherit') return null;
240
+ return setting;
241
+ }
@@ -50,7 +50,7 @@ export function missionLines({
50
50
  }) {
51
51
  if (state?.kind !== 'ok') return [];
52
52
  const working = state.lanes.filter((l) => l.name !== 'lead' && l.active && live[l.name]);
53
- const loopOn = Boolean(loop?.started) && !['off', 'stopped'].includes(loop?.state);
53
+ const loopOn = Boolean(loop?.started) && !['off', 'stopped', 'elsewhere'].includes(loop?.state);
54
54
  if (!leadBusy && !working.length && !loopOn) return [];
55
55
  const rows = [];
56
56
  const goal = goalOf(state.stories);
@@ -64,25 +64,29 @@ export function missionLines({
64
64
  ? `lead ${live.lead?.tool ? `at work: ${live.lead.tool}` : 'at work'}`
65
65
  : 'lead idle';
66
66
  const loopWord =
67
- loop?.state === 'paused'
68
- ? 'loop paused'
69
- : loop?.state === 'off'
70
- ? 'loop off'
71
- : loop?.state === 'done'
72
- ? 'milestone done'
73
- : loopOn
74
- ? `loop ${loop.autonomy === 'propose' ? 'proposing' : 'on'}${loop.last ? ` · last: ${loop.last}` : ''}`
75
- : null;
67
+ loop?.state === 'elsewhere'
68
+ ? 'loop in another session'
69
+ : loop?.state === 'paused'
70
+ ? 'loop paused'
71
+ : loop?.state === 'off'
72
+ ? 'loop off'
73
+ : loop?.state === 'done'
74
+ ? 'milestone done'
75
+ : loopOn
76
+ ? `loop ${loop.autonomy === 'propose' ? 'proposing' : 'on'}${loop.last ? ` · last: ${loop.last}` : ''}`
77
+ : null;
76
78
  rows.push({
77
79
  kind: 'now',
78
80
  label: 'now',
79
81
  text: [lead, loopWord].filter(Boolean).join(' · '),
80
82
  actions:
81
- loop?.state === 'paused'
82
- ? [{ id: 'resume', label: 'Resume' }]
83
- : loopOn && loop?.state === 'running'
84
- ? [{ id: 'pause', label: 'Pause' }]
85
- : [],
83
+ loop?.state === 'elsewhere'
84
+ ? [{ id: 'leadHere', label: 'Lead from here' }]
85
+ : loop?.state === 'paused'
86
+ ? [{ id: 'resume', label: 'Resume' }]
87
+ : loopOn && loop?.state === 'running'
88
+ ? [{ id: 'pause', label: 'Pause' }]
89
+ : [],
86
90
  });
87
91
  const next = nextText(nextStep);
88
92
  if (next && loop?.state !== 'done') rows.push({ kind: 'next', label: 'next', text: next });
@@ -51,8 +51,14 @@ import {
51
51
  toolLine,
52
52
  } from './panes.mjs';
53
53
  import {
54
+ agentFileModel,
55
+ answeredPrompt,
56
+ designHandedOver,
57
+ laneModel,
54
58
  leadPrompt,
55
59
  nudgeText,
60
+ outcomePrompt,
61
+ readOutcome,
56
62
  restartPrompt,
57
63
  stallDue,
58
64
  stepKey,
@@ -61,7 +67,7 @@ import {
61
67
  withoutInFlight,
62
68
  } from './loop.mjs';
63
69
  import { goalOf, nextText } from './mission.mjs';
64
- import { contextOf, decisionCards, limitsWhy } from './decisions.mjs';
70
+ import { contextOf, decisionCards, limitsWhy, nativeChoice } from './decisions.mjs';
65
71
  import { DEFAULT_TEAM_UI, TEAM_ID, teamFrame } from './teamview.mjs';
66
72
  import { glowOf, mapStoryOf, touchOf } from './codemap.mjs';
67
73
  import { harnessSettings } from '../lib/harness.mjs';
@@ -111,7 +117,22 @@ const loop = {
111
117
  nextStep: null, // what `code-kit next` last said, for the band's next row
112
118
  done: null, // { count } once every story is done
113
119
  doneDismissed: false,
120
+ // LOOP-11: the step the lead's turn was prompted with, until that turn ends: { prompt, key, label, kind, target }
121
+ sent: null,
122
+ // LOOP-11, LOOP-12: what holds the loop: { kind: 'person' | 'stopped' | 'blocked' | 'design', key, label, prompt, question, options, reason }
123
+ waiting: null,
124
+ handedDesign: new Set(), // LOOP-12: design stories handed to the lead this session
114
125
  };
126
+ // LOOP-10: whether this session runs the loop, the session that does when another does, and when this
127
+ // one last renewed its lease.
128
+ const lease = { mine: false, other: null, renewed: -Infinity };
129
+ const LEASE_RENEW_MS = 30000;
130
+ // LOOP-11: the lead's last reply, read once before the loop next prompts it: { turnId, text, aborted, sent, read }
131
+ let lastReply = null;
132
+ let turnQuestions = []; // the open questions as the lead's running turn started
133
+ // DECIDE-4: decisions asked in Claude Code's own question dialog this session, and whether one is open.
134
+ const offered = new Set();
135
+ let offering = false;
115
136
  /** LOOP-8: the times in a row the lead is prompted with one step before the person is asked. */
116
137
  const ASK_LIMIT = 2;
117
138
  let leadTurn = null; // the lead's running turn, if any
@@ -207,7 +228,7 @@ export function register(on) {
207
228
  const cli = `${$.plugin.root}/bin/code-kit.mjs`;
208
229
  const cwd = e.cwd ?? (await $.session.cwd());
209
230
  const session = await $.session.id();
210
- where = { cli, cwd, interactive: e.isInteractive !== false };
231
+ where = { cli, cwd, session, interactive: e.isInteractive !== false };
211
232
  // The held call's answer, for the `code-kit hold` it waits in.
212
233
  const answerHeld = (id, answer) =>
213
234
  $.process.run(['node', cli, 'hold', id, '--answer', answer], { cwd });
@@ -217,9 +238,12 @@ export function register(on) {
217
238
  };
218
239
  // What a CLI run said, in a line: why it refused, for a notice.
219
240
  const firstLine = (ran) => (ran.stderr || ran.stdout).trim().split('\n')[0];
220
- // A session starts with the loop running: a pause recorded by an earlier one no longer holds.
221
- if ((await json('loop').catch(() => null))?.paused)
241
+ // A session starts with the loop running: a pause recorded by an earlier one no longer holds. A pause
242
+ // by a session that still runs the loop (LOOP-10) is that session's, and stays.
243
+ const recorded = await json('loop').catch(() => null);
244
+ if (recorded?.paused && (!recorded.lead || recorded.lead.session === session))
222
245
  await $.process.run(['node', cli, 'loop', 'resume'], { cwd }).catch(() => {});
246
+ lease.other = recorded?.lead && recorded.lead.session !== session ? recorded.lead : null;
223
247
  const stamp = async (path) => {
224
248
  const s = await $.fs.stat(`${cwd}/${path}`).catch(() => null);
225
249
  return s ? `${path}@${s.mtimeMs}` : `${path}-`;
@@ -270,6 +294,11 @@ export function register(on) {
270
294
  boardSynced = at;
271
295
  $.process.run(['node', cli, 'team', 'board'], { cwd, timeoutMs: 120000 }).catch(() => {});
272
296
  }
297
+ // LOOP-10: while another session runs the loop, whether it still does (its lease lapses as it ends).
298
+ if (!lease.mine) {
299
+ const lead = (await json('loop').catch(() => null))?.lead ?? null;
300
+ lease.other = lead && lead.session !== session ? lead : null;
301
+ }
273
302
  // The agents at work; none where the session has none bound (claude -p before it mounts).
274
303
  const atWork = agentsAtWork(await $.agent.list().catch(() => []));
275
304
  model = {
@@ -634,6 +663,12 @@ export function register(on) {
634
663
  }
635
664
  if (q.kind === 'question')
636
665
  model = { ...model, questions: (model.questions ?? []).filter((x) => x.id !== q.id) };
666
+ // LOOP-11: answered, what the lead waited on no longer holds the loop.
667
+ if (
668
+ q.kind === 'lead' ||
669
+ (q.kind === 'question' && loop.waiting?.questionIds?.includes(q.id))
670
+ )
671
+ loop.waiting = null;
637
672
  $.ui.invalidate('ui.render');
638
673
  const said = /[.!?]$/.test(text) ? text : `${text}.`;
639
674
  $.prompt
@@ -641,7 +676,9 @@ export function register(on) {
641
676
  text:
642
677
  q.kind === 'question'
643
678
  ? `The person answered ${q.id} (${q.question}): ${said}${q.blocks ? ` ${q.story} no longer waits on them.` : ''} Carry on with it.`
644
- : `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.`,
679
+ : q.kind === 'lead'
680
+ ? answeredPrompt(q.question, text)
681
+ : `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.`,
645
682
  })
646
683
  .catch(() => {});
647
684
  await act.reload();
@@ -1004,17 +1041,184 @@ export function register(on) {
1004
1041
  loop.steps.push({ at: await $.clock.now(), kind, target, ...(prompt ? { prompt } : {}) });
1005
1042
  if (loop.steps.length > 50) loop.steps.shift();
1006
1043
  },
1007
- // LOOP-1: a prompt to the lead, recorded. It isn't awaited: the turn it starts runs on.
1008
- submit: async (prompt, kind, target) => {
1044
+ // LOOP-1: a prompt to the lead, recorded. It isn't awaited: the turn it starts runs on. LOOP-11:
1045
+ // the step is kept until that turn ends, so the loop reads the reply to it.
1046
+ submit: async (
1047
+ prompt,
1048
+ kind,
1049
+ target,
1050
+ { key = `${kind}:${target}`, label = `${kind} ${target}` } = {},
1051
+ ) => {
1009
1052
  loop.started = true;
1010
1053
  loop.proposal = null;
1054
+ loop.sent = { prompt, key, label, kind, target };
1055
+ // LOOP-12: a design story is handed to the lead once; the person designs it with the lead.
1056
+ if (kind === 'design')
1057
+ for (const id of String(target).split(/\s+/).filter(Boolean)) loop.handedDesign.add(id);
1011
1058
  await act.record(kind, target, prompt);
1012
1059
  $.ui.invalidate('ui.render');
1013
1060
  $.prompt.submit({ text: prompt }).catch((err) => {
1061
+ loop.sent = null;
1014
1062
  notice = `The loop couldn't prompt the lead: ${err?.message ?? err}`;
1015
1063
  $.ui.invalidate('ui.render');
1016
1064
  });
1017
1065
  },
1066
+ // LOOP-10: this session takes or renews the lease of the one session that runs the loop; another
1067
+ // session holding it keeps it, unless the person moves it here (`take`).
1068
+ lead: async ({ take = false } = {}) => {
1069
+ const now = await $.clock.now();
1070
+ if (lease.mine && !take && now - lease.renewed < LEASE_RENEW_MS) return true;
1071
+ const ran = await $.process
1072
+ .run(['node', cli, 'loop', 'lead', '--session', session, ...(take ? ['--take'] : [])], {
1073
+ cwd,
1074
+ })
1075
+ .catch(() => ({ exitCode: 1 }));
1076
+ const was = lease.mine;
1077
+ lease.mine = ran.exitCode === 0;
1078
+ lease.renewed = now;
1079
+ lease.other = lease.mine ? null : ((await json('loop').catch(() => null))?.lead ?? null);
1080
+ // Taken over by another session: this one stops prompting its lead, and offers nothing more.
1081
+ if (was && !lease.mine) {
1082
+ loop.proposal = null;
1083
+ loop.pending = [];
1084
+ loop.waiting = null;
1085
+ }
1086
+ if (was !== lease.mine || !lease.mine) $.ui.invalidate('ui.render');
1087
+ return lease.mine;
1088
+ },
1089
+ // LOOP-10: the person moves the loop to this session; the other stops prompting its lead.
1090
+ leadHere: async () => {
1091
+ if (!(await act.lead({ take: true }))) {
1092
+ notice = "This session couldn't take the loop: code-kit loop lead failed.";
1093
+ $.ui.invalidate('ui.render');
1094
+ return;
1095
+ }
1096
+ loop.lastKey = null;
1097
+ await act.record('lead', 'this session');
1098
+ $.ui.invalidate('ui.render');
1099
+ await act.step('resume');
1100
+ },
1101
+ // LOOP-11: the lead's last reply, read once before the loop prompts it again. A reply that waits on
1102
+ // the person, or a turn they stopped, holds the loop until they speak; a step the lead didn't take
1103
+ // holds that step while it's still next.
1104
+ hear: async () => {
1105
+ const reply = lastReply;
1106
+ if (!reply || reply.read) return;
1107
+ reply.read = true;
1108
+ const sent = reply.sent;
1109
+ const step = sent
1110
+ ? {
1111
+ key: sent.key,
1112
+ label: sent.label,
1113
+ prompt: sent.prompt,
1114
+ stepKind: sent.kind,
1115
+ target: sent.target,
1116
+ }
1117
+ : {};
1118
+ if (reply.aborted) {
1119
+ loop.waiting = { kind: 'stopped', ...step };
1120
+ $.ui.invalidate('ui.render');
1121
+ return;
1122
+ }
1123
+ // It asked through code-kit: those questions are the person's decisions already.
1124
+ const open = ((await json('ask').catch(() => null)) ?? []).filter(
1125
+ (q) => q.state === 'open',
1126
+ );
1127
+ const asked = open.filter((q) => !reply.asked.includes(q.id));
1128
+ if (asked.length) {
1129
+ model = { ...model, questions: open };
1130
+ loop.waiting = {
1131
+ kind: 'person',
1132
+ ...step,
1133
+ questionIds: asked.map((q) => q.id),
1134
+ reason: `it asked you ${asked.map((q) => q.id).join(', ')} through code-kit`,
1135
+ };
1136
+ $.ui.invalidate('ui.render');
1137
+ return;
1138
+ }
1139
+ if (!reply.text.trim()) return;
1140
+ const said = await act.sideCall(
1141
+ outcomePrompt({ prompt: sent?.prompt ?? null, answer: reply.text }),
1142
+ );
1143
+ const heard = said.isAnswered ? readOutcome(said.text) : null;
1144
+ // Spec 05's data: what came of the step, once known.
1145
+ const record = sent ? loop.steps.findLast((x) => x.prompt === sent.prompt) : null;
1146
+ if (record) record.outcome = heard?.outcome ?? 'unread';
1147
+ if (heard?.outcome === 'needs-person')
1148
+ loop.waiting = {
1149
+ kind: 'person',
1150
+ ...step,
1151
+ question: heard.question,
1152
+ options: heard.options,
1153
+ reason: heard.reason,
1154
+ };
1155
+ else if (heard?.outcome === 'blocked' && sent)
1156
+ loop.waiting = { kind: 'blocked', ...step, reason: heard.reason };
1157
+ if (loop.waiting) $.ui.invalidate('ui.render');
1158
+ },
1159
+ // LOOP-11: the person's choice from the lead's options, or their own words, told to the lead.
1160
+ answerLead: (line) =>
1161
+ act.sendAnswer({ kind: 'lead', question: line.waiting.question }, line.choice),
1162
+ answerLeadTyped: (line, text) =>
1163
+ act.sendAnswer({ kind: 'lead', question: line.waiting.question }, text),
1164
+ answerLeadText: async (line) => {
1165
+ answering = {
1166
+ kind: 'lead',
1167
+ question: line.waiting.question,
1168
+ options: line.waiting.options ?? [],
1169
+ text: '',
1170
+ error: null,
1171
+ aside: { thread: [], draft: '' },
1172
+ };
1173
+ await $.ui.open({ id: ANSWER_ID, title: 'Answer', focus: true, closeOnEscape: true });
1174
+ $.ui.invalidate('ui.render');
1175
+ },
1176
+ // DECIDE-4: a question's answer typed in the dialog, as the person's own words.
1177
+ answerTyped: (line, text) => act.sendAnswer({ kind: 'question', ...line.question }, text),
1178
+ // LOOP-11, LOOP-12: the person asks the lead again for the step that held the loop.
1179
+ askAgain: async () => {
1180
+ const w = loop.waiting;
1181
+ if (!w?.prompt || leadTurn) return;
1182
+ loop.waiting = null;
1183
+ loop.asked = { key: w.key, times: 0 };
1184
+ await act.submit(w.prompt, w.stepKind, w.target, { key: w.key, label: w.label });
1185
+ },
1186
+ // LOOP-11: the person lets the loop go on.
1187
+ carryOn: async () => {
1188
+ loop.waiting = null;
1189
+ loop.lastKey = null;
1190
+ $.ui.invalidate('ui.render');
1191
+ await act.step('resume');
1192
+ },
1193
+ // DECIDE-4: the next decision the person settles with a choice, asked in Claude Code's own question
1194
+ // dialog while the lead is idle. Each is asked once: dismissed, it stays in the band and the
1195
+ // Decisions tab. Never in a session another one runs the loop for, nor over what the person types.
1196
+ offer: async () => {
1197
+ if (offering || leadTurn || loop.sent || !where.interactive) return;
1198
+ if (lease.other && !lease.mine) return;
1199
+ const now = await $.clock.now();
1200
+ const choice = cardsNow(now)
1201
+ .map((card) => ({ card, choice: nativeChoice(card) }))
1202
+ .find((c) => c.choice && !offered.has(c.choice.key));
1203
+ if (!choice) return;
1204
+ const box = await $.prompt.read().catch(() => null);
1205
+ if (box?.text?.trim()) return;
1206
+ offered.add(choice.choice.key);
1207
+ offering = true;
1208
+ const { question, header, options, typed } = choice.choice;
1209
+ const answer = await $.ui
1210
+ .ask(question, { options: options.map((o) => o.label), header })
1211
+ .catch(() => null)
1212
+ .finally(() => {
1213
+ offering = false;
1214
+ });
1215
+ if (answer == null) return;
1216
+ const picked = options.find((o) => o.label === answer);
1217
+ if (picked?.act) await act[picked.act](picked.line);
1218
+ else if (!picked && typed && answer.trim())
1219
+ await act[typed](choice.card.line, answer.trim());
1220
+ act.offer().catch(() => {});
1221
+ },
1018
1222
  // LOOP-1, LOOP-5, LOOP-7: with the lead idle and no draft, the step `next` reports.
1019
1223
  step: async (trigger) => {
1020
1224
  const h = harness();
@@ -1023,6 +1227,11 @@ export function register(on) {
1023
1227
  if (trigger === 'tick' && !loop.started && !loop.pending.length) return;
1024
1228
  const box = await $.prompt.read().catch(() => null);
1025
1229
  if (box?.text?.trim()) return;
1230
+ // LOOP-10: one session in the project runs the loop; in any other, the lead is the person's alone.
1231
+ if (!(await act.lead())) return;
1232
+ // LOOP-11: the lead's last reply first: one that waits on the person holds every step.
1233
+ await act.hear();
1234
+ if (loop.waiting && ['person', 'stopped'].includes(loop.waiting.kind)) return;
1026
1235
  if (loop.pending.length) {
1027
1236
  const { story, prompt } = loop.pending.shift();
1028
1237
  // Until the person has chosen how hands-off the loop is, or under propose, a restart is offered
@@ -1034,6 +1243,7 @@ export function register(on) {
1034
1243
  label: `Restart ${story}`,
1035
1244
  kind: 'restart',
1036
1245
  target: story,
1246
+ key: `restart:${story}`,
1037
1247
  first: !chosenYet,
1038
1248
  };
1039
1249
  $.ui.invalidate('ui.render');
@@ -1080,7 +1290,19 @@ export function register(on) {
1080
1290
  loop.asked = { key, times: 0 };
1081
1291
  if (loop.proposal?.repeated) loop.proposal = null;
1082
1292
  }
1293
+ // LOOP-11, LOOP-12: a step the lead didn't take, or designs left to the person and the lead, hold
1294
+ // that step while it's still next; any other step goes on.
1295
+ if (loop.waiting) {
1296
+ if (loop.waiting.key === key) return;
1297
+ loop.waiting = null;
1298
+ $.ui.invalidate('ui.render');
1299
+ }
1083
1300
  if (!prompt) return;
1301
+ if (designHandedOver(step, loop.handedDesign)) {
1302
+ loop.waiting = { kind: 'design', key, label, prompt, stepKind: kind, target };
1303
+ $.ui.invalidate('ui.render');
1304
+ return;
1305
+ }
1084
1306
  // Never the same step twice while the project stands still.
1085
1307
  const print = await act.fingerprint();
1086
1308
  if (key === loop.lastKey && print === loop.lastPrint) return;
@@ -1090,7 +1312,7 @@ export function register(on) {
1090
1312
  // moving elsewhere (lanes committing) doesn't make a third prompt useful. The person decides.
1091
1313
  if (loop.asked.times >= ASK_LIMIT) {
1092
1314
  if (loop.proposal?.label !== label) {
1093
- loop.proposal = { prompt, label, kind, target, repeated: loop.asked.times };
1315
+ loop.proposal = { prompt, label, kind, target, key, repeated: loop.asked.times };
1094
1316
  $.ui.invalidate('ui.render');
1095
1317
  }
1096
1318
  return;
@@ -1098,12 +1320,12 @@ export function register(on) {
1098
1320
  // Until the person has chosen how hands-off the loop is, its first step is offered, not taken.
1099
1321
  const chosen = (model.check?.harnessSet ?? []).includes('autonomy');
1100
1322
  if (h.autonomy === 'propose' || !chosen) {
1101
- loop.proposal = { prompt, label, kind, target, first: !chosen };
1323
+ loop.proposal = { prompt, label, kind, target, key, first: !chosen };
1102
1324
  $.ui.invalidate('ui.render');
1103
1325
  return;
1104
1326
  }
1105
1327
  loop.asked.times += 1;
1106
- await act.submit(prompt, kind, target);
1328
+ await act.submit(prompt, kind, target, { key, label });
1107
1329
  },
1108
1330
  // The first offer's answers: the person's choice of autonomy, recorded as theirs, then acted on.
1109
1331
  chooseAutonomy: async (value) => {
@@ -1146,10 +1368,10 @@ export function register(on) {
1146
1368
  },
1147
1369
  go: async () => {
1148
1370
  if (!loop.proposal || leadTurn) return;
1149
- const { prompt, kind, target, repeated } = loop.proposal;
1371
+ const { prompt, kind, target, key, label, repeated } = loop.proposal;
1150
1372
  // The person asked again: once more, then it's theirs to offer again if it still doesn't take.
1151
1373
  loop.asked.times = repeated ? ASK_LIMIT : loop.asked.times + 1;
1152
- await act.submit(prompt, kind, target);
1374
+ await act.submit(prompt, kind, target, { key, label });
1153
1375
  },
1154
1376
  // LOOP-6: pausing stops new steps at once; a turn in progress finishes.
1155
1377
  pause: async () => {
@@ -1405,6 +1627,9 @@ export function register(on) {
1405
1627
  if (busy) return;
1406
1628
  busy = true;
1407
1629
  try {
1630
+ // LOOP-10: the lead session renews its lease; DECIDE-4: with the lead idle, a decision waiting.
1631
+ if (lease.mine) await act.lead();
1632
+ if (!leadTurn) act.offer().catch(() => {});
1408
1633
  await act.stalls();
1409
1634
  await act.animate();
1410
1635
  const now = await act.fingerprint();
@@ -1605,6 +1830,26 @@ export function register(on) {
1605
1830
  });
1606
1831
  });
1607
1832
 
1833
+ // SET-4: a lane agent starts on the lane model the person set, unless the lead's call names one or
1834
+ // the lane's agent file sets its own.
1835
+ on('agent.spawn', async ($, e, next) => {
1836
+ const setting = harness().agents.lanes.model;
1837
+ const lanes = model.check?.lanes ?? {};
1838
+ if (!setting || e.model || !Object.values(lanes).some((l) => l.agent === e.subagentType))
1839
+ return next(e);
1840
+ const file = where
1841
+ ? await $.fs.read(`${where.cwd}/.claude/agents/${e.subagentType}.md`).catch(() => null)
1842
+ : null;
1843
+ const chosen = laneModel({
1844
+ type: e.subagentType,
1845
+ model: e.model,
1846
+ lanes,
1847
+ setting,
1848
+ fileModel: typeof file === 'string' ? agentFileModel(file) : null,
1849
+ });
1850
+ return next(chosen ? { ...e, model: chosen } : e);
1851
+ }).catch(($, e, next) => next(e));
1852
+
1608
1853
  // USE-1: each model request's usage, as the request ends; the lead's requests carry no agentId.
1609
1854
  on('turn.step', async function* ($, e, next) {
1610
1855
  const res = yield* next(e);
@@ -1629,6 +1874,8 @@ export function register(on) {
1629
1874
 
1630
1875
  on('turn.start', async ($, e, next) => {
1631
1876
  leadTurn = e.turnId;
1877
+ // LOOP-11: the questions already open, so the loop knows which the lead asked in this turn.
1878
+ turnQuestions = (model.questions ?? []).map((q) => q.id);
1632
1879
  $.ui.invalidate('ui.render'); // the lead's lane reads working while its turn runs
1633
1880
  if (reviewTurn === 'next') reviewTurn = e.turnId;
1634
1881
  return next(e);
@@ -1650,6 +1897,16 @@ export function register(on) {
1650
1897
  // as the spike found it must be). The prompt it submits isn't awaited: that turn isn't this hook's.
1651
1898
  if (!e.agentId && (!leadTurn || leadTurn === e.turnId)) {
1652
1899
  leadTurn = null;
1900
+ // LOOP-11: the lead's reply, read before the loop next prompts it, with the step it answered.
1901
+ lastReply = {
1902
+ turnId: e.turnId,
1903
+ text: e.answer ?? '',
1904
+ aborted: Boolean(e.isAborted),
1905
+ sent: loop.sent,
1906
+ asked: turnQuestions,
1907
+ read: false,
1908
+ };
1909
+ loop.sent = null;
1653
1910
  $.ui.invalidate('ui.render');
1654
1911
  // What the person asked for goes first, whatever the loop's state.
1655
1912
  if (personAsks.length) {
@@ -1658,15 +1915,47 @@ export function register(on) {
1658
1915
  return res;
1659
1916
  }
1660
1917
  await act?.step('turn').catch(() => {});
1918
+ // DECIDE-4: a decision waiting, asked in Claude Code's own dialog; not awaited, the person answers.
1919
+ act?.offer().catch(() => {});
1661
1920
  }
1662
1921
  return res;
1663
1922
  });
1664
1923
 
1924
+ // LOOP-11: the person speaking to the lead answers what the loop waited on, whatever they said; the
1925
+ // loop reads the lead's reply to them before it prompts again. Designs stay theirs (LOOP-12).
1926
+ on('prompt.submit', async ($, e, next) => {
1927
+ if (['composer', 'bridge'].includes(e.origin?.kind) && loop.waiting?.kind !== 'design') {
1928
+ if (loop.waiting) $.ui.invalidate('ui.render');
1929
+ loop.waiting = null;
1930
+ loop.asked = { key: null, times: 0 };
1931
+ }
1932
+ return next(e);
1933
+ });
1934
+
1935
+ // LOOP-10: the lead session ends: the next session to act takes the loop at once.
1936
+ on('session.end', async ($, e, next) => {
1937
+ if (lease.mine && where) {
1938
+ lease.mine = false;
1939
+ await $.process
1940
+ .run(['node', where.cli, 'loop', 'release', '--session', where.session], { cwd: where.cwd })
1941
+ .catch(() => {});
1942
+ }
1943
+ return next(e);
1944
+ });
1945
+
1665
1946
  // What the pane and band show of the loop.
1666
1947
  const loopNow = (now) => {
1667
1948
  const h = harness();
1668
1949
  const state =
1669
- h.autonomy === 'off' ? 'off' : loop.done ? 'done' : loop.paused ? 'paused' : 'running';
1950
+ h.autonomy === 'off'
1951
+ ? 'off'
1952
+ : lease.other && !lease.mine
1953
+ ? 'elsewhere'
1954
+ : loop.done
1955
+ ? 'done'
1956
+ : loop.paused
1957
+ ? 'paused'
1958
+ : 'running';
1670
1959
  const laneNotes = {};
1671
1960
  for (const a of activity.values()) {
1672
1961
  if (!a.lane) continue;
@@ -1684,6 +1973,7 @@ export function register(on) {
1684
1973
  started: loop.started,
1685
1974
  last: last ? `${last.kind} ${last.target}` : null,
1686
1975
  proposal: loop.proposal,
1976
+ waiting: loop.waiting,
1687
1977
  flagged: [...flagged.values()],
1688
1978
  doneCount: loop.done?.count ?? 0,
1689
1979
  steps: loop.steps,
@@ -1820,6 +2110,7 @@ export function register(on) {
1820
2110
  flag: (t) => `${t} stalled again after its restarts, so it came to you.`,
1821
2111
  pause: () => 'You paused the loop.',
1822
2112
  resume: () => 'You resumed the loop.',
2113
+ lead: () => 'You moved the loop to this session.',
1823
2114
  done: (t) => `Every story was done (${t}).`,
1824
2115
  };
1825
2116
  const journalNow = () => {
@@ -1829,7 +2120,7 @@ export function register(on) {
1829
2120
  .map((x) => ({
1830
2121
  at: x.at,
1831
2122
  kind: 'loop',
1832
- who: ['pause', 'resume'].includes(x.kind) ? 'person' : 'lead',
2123
+ who: ['pause', 'resume', 'lead'].includes(x.kind) ? 'person' : 'lead',
1833
2124
  text: STEP_WORDS[x.kind](x.target),
1834
2125
  }));
1835
2126
  return [...recorded, ...steps].sort((a, b) => a.at - b.at);