@milpa/comal 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,7 @@ comal --help
28
28
 
29
29
  Keep the PATH line in your shell startup file if you use that prefix. If your Node manager already provides a user prefix, you can omit `--prefix "$HOME/.local"`. The package registers the `comal` executable; you do not need to create a shell alias.
30
30
 
31
- You can pin a version with `npx @milpa/comal@0.2.6` or `npm install --global @milpa/comal@0.2.6`. The local release manifest preserves SHA-256 and SHA-512 integrity is checked against npm at publish time. No independent publisher signature is claimed.
31
+ You can pin a version with `npx @milpa/comal@0.2.7` or `npm install --global @milpa/comal@0.2.7`. The local release manifest preserves SHA-256 and SHA-512 integrity is checked against npm at publish time. No independent publisher signature is claimed.
32
32
 
33
33
  ## Test without account or model
34
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milpa/comal",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Comal: governed operations, execution receipts, and programming assistance in any language",
5
5
  "type": "module",
6
6
  "private": false,
@@ -0,0 +1,106 @@
1
+ import net from 'node:net';
2
+ import path from 'node:path';
3
+
4
+ // What an interactive Comal is doing, said to the outside in two ways:
5
+ // - the terminal title (OSC 0), which any terminal, tab bar or workspace manager can read:
6
+ // "◐ Comal · name" while it works, "? Comal · name" while a question waits for you, "✳ Comal · name" when idle;
7
+ // - herdr's agent socket (pane.report_agent), when Comal runs inside a herdr pane (HERDR_ENV=1).
8
+ // Both are best-effort: a failure is ignored and never touches the session. COMAL_TERMINAL_TITLE=off keeps the title.
9
+
10
+ export const STATES = Object.freeze(['working', 'blocked', 'idle']);
11
+ const GLYPH = { working: '◐', blocked: '?', idle: '✳' };
12
+ const TITLE_MAX = 80;
13
+
14
+ /**
15
+ * blocked while a permission card, or a choice or input pushed to the human (setup.waits: a swarm card, a connection
16
+ * review), waits for an answer; working while a turn runs; idle otherwise. A picker the human opened is not a question.
17
+ */
18
+ export function agentState({ busy = false, permission = null, setup = null } = {}) {
19
+ if (permission || setup?.waits) return 'blocked';
20
+ return busy ? 'working' : 'idle';
21
+ }
22
+
23
+ const clean = text => String(text ?? '').replace(/[\x00-\x1f\x7f-\x9f]/g, '').replace(/\s+/g, ' ').trim();
24
+
25
+ /** The label after "Comal ·": the session name, else the folder name. */
26
+ export function statusLabel({ name, cwd } = {}) {
27
+ return clean(name) || clean(cwd ? path.basename(cwd) : '');
28
+ }
29
+
30
+ export function terminalTitle(state, label) {
31
+ const text = `${GLYPH[state] || GLYPH.idle} Comal${label ? ' · ' + clean(label) : ''}`;
32
+ return [...text].slice(0, TITLE_MAX).join('');
33
+ }
34
+
35
+ /** Sends one JSON line to herdr's socket; resolves true when herdr answered. Never rejects. */
36
+ function sendLine(socketPath, request, { connect = net.createConnection, timeoutMs = 500 } = {}) {
37
+ return new Promise(resolve => {
38
+ let done = false, socket, timer;
39
+ const finish = ok => { if (done) return; done = true; clearTimeout(timer); try { socket?.destroy(); } catch {} resolve(ok); };
40
+ try { socket = connect(socketPath); } catch { finish(false); return; }
41
+ socket.on('error', () => finish(false));
42
+ socket.on('connect', () => socket.write(JSON.stringify(request) + '\n'));
43
+ socket.on('data', () => finish(true));
44
+ socket.on('end', () => finish(false));
45
+ timer = setTimeout(() => finish(false), timeoutMs); timer.unref?.();
46
+ });
47
+ }
48
+
49
+ export class HerdrReporter {
50
+ constructor({ env = process.env, connect, now = Date.now } = {}) {
51
+ this.socketPath = env.HERDR_SOCKET_PATH; this.paneId = env.HERDR_PANE_ID; this.connect = connect; this.now = now;
52
+ this.enabled = env.HERDR_ENV === '1' && Boolean(this.socketPath) && Boolean(this.paneId);
53
+ this.seq = now() * 1000; this.sessionId = null; this.chain = Promise.resolve();
54
+ }
55
+ // Reports go out one at a time, in order, each with a rising seq, so herdr never keeps an older state.
56
+ send(method, params) {
57
+ if (!this.enabled) return this.chain;
58
+ const request = { id: `comal:${method}:${this.now()}:${Math.random().toString(36).slice(2, 8)}`, method, params: { pane_id: this.paneId, source: 'comal', agent: 'comal', ...params } };
59
+ this.chain = this.chain.then(() => sendLine(this.socketPath, request, { connect: this.connect }));
60
+ return this.chain;
61
+ }
62
+ session(id) {
63
+ if (!id || id === this.sessionId) return this.chain;
64
+ this.sessionId = id;
65
+ return this.send('pane.report_agent_session', { seq: ++this.seq, agent_session_id: id });
66
+ }
67
+ report(state, message) {
68
+ return this.send('pane.report_agent', { state, ...(message ? { message } : {}), seq: ++this.seq, ...(this.sessionId ? { agent_session_id: this.sessionId } : {}) });
69
+ }
70
+ release() { return this.send('pane.release_agent', {}); }
71
+ }
72
+
73
+ export class AgentStatus {
74
+ constructor({ output, env = process.env, herdr = new HerdrReporter({ env }), exitTarget = process } = {}) {
75
+ // Only a real terminal says anything: a screen drawn into a pipe or a test double reports nothing anywhere.
76
+ this.output = output; this.herdr = herdr; this.live = Boolean(output?.isTTY);
77
+ this.titles = this.live && !/^(?:off|0|false|no)$/i.test(env.COMAL_TERMINAL_TITLE || '');
78
+ this.title = null; this.state = null; this.message = null; this.closed = false; this.pushed = false;
79
+ // A crash or an exit that never reaches close() still puts the terminal's title back.
80
+ this.exitTarget = exitTarget; this.onExit = () => { if (this.pushed && !this.closed) { try { this.output.write('\x1b[23;0t'); } catch {} } };
81
+ }
82
+ /** The next update writes the title again (after an editor or another program owned the terminal). */
83
+ forgetTitle() { this.title = null; }
84
+ update({ state, label, message, sessionId, titles = true } = {}) {
85
+ if (this.closed || !this.live || !STATES.includes(state)) return;
86
+ if (this.titles && titles) {
87
+ const title = terminalTitle(state, label);
88
+ if (title !== this.title) {
89
+ // Save the terminal's own title once, so closing puts it back (xterm title stack; ignored where unsupported).
90
+ try {
91
+ if (!this.pushed) this.exitTarget?.once?.('exit', this.onExit);
92
+ this.output.write((this.pushed ? '' : '\x1b[22;0t') + `\x1b]0;${title}\x07`); this.pushed = true; this.title = title;
93
+ } catch {}
94
+ }
95
+ }
96
+ if (sessionId) this.herdr.session(sessionId);
97
+ const text = state === 'blocked' ? clean(message).slice(0, 200) : '';
98
+ if (state !== this.state || text !== this.message) { this.state = state; this.message = text; this.herdr.report(state, text || undefined); }
99
+ }
100
+ close() {
101
+ if (this.closed) return this.herdr.chain; this.closed = true;
102
+ this.exitTarget?.off?.('exit', this.onExit);
103
+ if (this.pushed) { try { this.output.write('\x1b[23;0t'); } catch {} }
104
+ return this.state ? this.herdr.release() : this.herdr.chain;
105
+ }
106
+ }
package/src/i18n/es.mjs CHANGED
@@ -160,6 +160,8 @@ export default Object.freeze({
160
160
  "vision": "visión",
161
161
  "connecting": "conectando",
162
162
  "Enlarge the terminal to 24×10": "Amplía el terminal a 24×10",
163
+ "Permission: {v0}": "Permiso: {v0}",
164
+ "Waiting for your answer": "Esperando tu respuesta",
163
165
  " {v0} interrupted message(s); not resent. R requeues the first.": " {v0} mensaje(s) interrumpidos; no se reenviaron. R reencola el primero.",
164
166
  " Empty queue. Send a message while the agent works.": " Cola vacía. Envía un mensaje mientras el agente trabaja.",
165
167
  " Running: ": " En ejecución: ",
@@ -558,7 +558,7 @@ export class RelayFlow {
558
558
  if (got?.proposal?.status && got.proposal.status !== 'pending') { this.ui.notice(t('Plan {id} is already {status}.', { id: proposal_id, status: got.proposal.status })); return 'decided'; }
559
559
  // Cómo se comprueba cada tarea: la descripción de los checks del proyecto (project.json del hub).
560
560
  const checks = (await this.#projectFile({ root }, project))?.checks || [];
561
- const choice = await this.ui.setupChoice({ ...planCard({ got, checks }), signal: card.signal });
561
+ const choice = await this.ui.setupChoice({ waits: true, ...planCard({ got, checks }), signal: card.signal });
562
562
  if (this.#elsewhere(key)) return 'decided';
563
563
  const digest = got.proposal.digest, apply = extra => () => this.applyPlan({ project, root, swarm, proposal_id, digest, ...extra });
564
564
  const done = result => {
@@ -569,7 +569,7 @@ export class RelayFlow {
569
569
  };
570
570
  if (choice === 'approve') return await this.#confirm(key, apply({ choice: 'approve' }), done);
571
571
  if (choice === 'reject') {
572
- const reason = await this.ui.setupInput({ title: t('Reason for the lead'), description: t('The lead is woken up with exactly this text and proposes again.'), signal: card.signal });
572
+ const reason = await this.ui.setupInput({ waits: true, title: t('Reason for the lead'), description: t('The lead is woken up with exactly this text and proposes again.'), signal: card.signal });
573
573
  if (this.#elsewhere(key)) return 'decided';
574
574
  if (!reason?.trim()) return 'later';
575
575
  return await this.#confirm(key, apply({ choice: 'reject', reason: reason.trim() }), done);
@@ -618,7 +618,7 @@ export class RelayFlow {
618
618
  if (this.#elsewhere(key)) return 'decided';
619
619
  const card = this.broker.open(key, this.signal);
620
620
  try {
621
- const choice = await this.ui.setupChoice({ ...criteriaCard({ got, swarm }), signal: card.signal });
621
+ const choice = await this.ui.setupChoice({ waits: true, ...criteriaCard({ got, swarm }), signal: card.signal });
622
622
  if (this.#elsewhere(key)) return 'decided';
623
623
  const done = result => {
624
624
  if (result.outcome === 'approved') this.ui.notice(t('Success criteria v{version} accepted ({status}): the lead can plan.', { version: got.current.version, status: result.status }));
@@ -630,14 +630,14 @@ export class RelayFlow {
630
630
  // open-relay v2.33: con avisos, aceptar se confirma aparte (por omisión, volver).
631
631
  const warnings = criteriaWarnings(got.current.items, { checked: got.basis_checked === true });
632
632
  if (warnings.size) {
633
- const sure = await this.ui.setupChoice({ ...criteriaConfirmCard({ got, swarm, warnings }), signal: card.signal });
633
+ const sure = await this.ui.setupChoice({ waits: true, ...criteriaConfirmCard({ got, swarm, warnings }), signal: card.signal });
634
634
  if (this.#elsewhere(key)) return 'decided';
635
635
  if (sure !== 'accept') return 'later';
636
636
  }
637
637
  return await this.#confirm(key, () => this.applyCriteria({ project, root, swarm, digest, choice: 'accept' }), done);
638
638
  }
639
639
  if (choice === 'reject') {
640
- const reason = await this.ui.setupInput({ title: t('Reason for the reviewer'), description: t('The reviewer is woken up with exactly this text and proposes again.'), signal: card.signal });
640
+ const reason = await this.ui.setupInput({ waits: true, title: t('Reason for the reviewer'), description: t('The reviewer is woken up with exactly this text and proposes again.'), signal: card.signal });
641
641
  if (this.#elsewhere(key)) return 'decided';
642
642
  if (!reason?.trim()) return 'later';
643
643
  return await this.#confirm(key, () => this.applyCriteria({ project, root, swarm, digest, choice: 'reject', reason: reason.trim() }), done);
@@ -653,7 +653,7 @@ export class RelayFlow {
653
653
  const { call, mutate } = this.#calls(project, root);
654
654
  const got = await call('criteria.get', { swarm_id: swarm }, this.signal);
655
655
  if (got?.status !== 'accepted' || got?.current?.digest !== digest) { this.ui.notice(t('Criteria of {swarm} are already {status}.', { swarm, status: got?.status || '?' })); return 'decided'; }
656
- const reason = await this.ui.setupInput({ title: t('Why reopen the criteria'), description: t('The reviewer is woken up with exactly this text and proposes a new version; v{version} stays in force until you accept the new one.', { version: got.current.version }), signal: this.signal });
656
+ const reason = await this.ui.setupInput({ waits: true, title: t('Why reopen the criteria'), description: t('The reviewer is woken up with exactly this text and proposes a new version; v{version} stays in force until you accept the new one.', { version: got.current.version }), signal: this.signal });
657
657
  if (!reason?.trim()) return 'later';
658
658
  const result = await reopenCriteria(mutate, { swarm_id: swarm, digest, reason: reason.trim() }, this.signal);
659
659
  await this.record({ action: 'criteria.reopen', project, swarm, digest, version: got.current.version, reason: reason.trim(), status: result.status, idempotency_key: result.idempotency_key });
@@ -683,9 +683,9 @@ export class RelayFlow {
683
683
  const { call, mutate } = this.#calls(project, root);
684
684
  const got = await call('task.get', { task_id: brief?.id }, this.signal), task = got?.task || got;
685
685
  if (task?.state !== 'needs_attention' || !task?.review?.pending) { this.ui.notice(t('The review of {task} no longer waits for you.', { task: line(brief?.id || '?', 60) })); return 'decided'; }
686
- const choice = await this.ui.setupChoice({ ...escalationCard({ task }), signal: this.signal });
686
+ const choice = await this.ui.setupChoice({ waits: true, ...escalationCard({ task }), signal: this.signal });
687
687
  if (choice === 'retry') {
688
- const why = await this.ui.setupInput({ title: t('Why another review'), description: t('Recorded in relayd as your decision (review.retry) with exactly this text; the reviewer gets a new review task.'), signal: this.signal });
688
+ const why = await this.ui.setupInput({ waits: true, title: t('Why another review'), description: t('Recorded in relayd as your decision (review.retry) with exactly this text; the reviewer gets a new review task.'), signal: this.signal });
689
689
  if (!why?.trim()) return 'later';
690
690
  const retried = await retryReview(mutate, { task_id: task.id, reason: why.trim() }, this.signal);
691
691
  await this.record({ action: 'review.retry', project, swarm, task: task.id, reason: why.trim(), status: retried.status, idempotency_key: retried.idempotency_key });
@@ -693,7 +693,7 @@ export class RelayFlow {
693
693
  return 'decided';
694
694
  }
695
695
  if (choice !== 'override') return 'later';
696
- const reason = await this.ui.setupInput({ title: t('Why you accept over the reviewer'), description: t('Recorded in relayd as your decision (review.override) with exactly this text.'), signal: this.signal });
696
+ const reason = await this.ui.setupInput({ waits: true, title: t('Why you accept over the reviewer'), description: t('Recorded in relayd as your decision (review.override) with exactly this text.'), signal: this.signal });
697
697
  if (!reason?.trim()) return 'later';
698
698
  const result = await overrideReview(mutate, { task_id: task.id, reason: reason.trim() }, this.signal);
699
699
  await this.record({ action: 'review.override', project, swarm, task: task.id, reason: reason.trim(), status: result.status, idempotency_key: result.idempotency_key });
@@ -723,7 +723,7 @@ export class RelayFlow {
723
723
  try {
724
724
  const { call } = this.#calls(project, root);
725
725
  const message = await call('msg.get', { message_id }, card.signal), block = decisionBlock(message);
726
- const choice = await this.ui.setupChoice({ ...questionCard({ message, block, task }), signal: card.signal });
726
+ const choice = await this.ui.setupChoice({ waits: true, ...questionCard({ message, block, task }), signal: card.signal });
727
727
  if (this.#elsewhere(key)) return 'decided';
728
728
  const done = result => {
729
729
  if (result.outcome === 'decided') this.ui.notice(t('Decision recorded as {adr} and sent to {to}.', { adr: result.adr || '?', to: result.to }));
@@ -733,14 +733,14 @@ export class RelayFlow {
733
733
  };
734
734
  if (choice?.startsWith('option:') && block) {
735
735
  const index = Number(choice.slice(7)), option = block.options[index]?.label; if (!option) return 'later';
736
- const note = await this.ui.setupInput({ title: t('Note for the ADR (optional)'), description: t('Why you choose «{option}». Enter with nothing skips it; it goes into the ADR decision.', { option }), signal: card.signal });
736
+ const note = await this.ui.setupInput({ waits: true, title: t('Note for the ADR (optional)'), description: t('Why you choose «{option}». Enter with nothing skips it; it goes into the ADR decision.', { option }), signal: card.signal });
737
737
  if (this.#elsewhere(key)) return 'decided';
738
738
  if (note === null || note === undefined) return 'later';
739
739
  return await this.#confirm(key, () => this.applyQuestion({ project, root, swarm, message_id, mode: 'option', option_index: index, text: note }), done);
740
740
  }
741
741
  if (choice === 'reply') {
742
742
  const to = message?.from?.role ? `role ${line(message.from.role, 60)}` : line(message?.from?.kind || '?', 40);
743
- const body = await this.ui.setupInput({ title: t('Your answer'), description: t('Exact text sent to {to} (msg.reply).', { to }), signal: card.signal });
743
+ const body = await this.ui.setupInput({ waits: true, title: t('Your answer'), description: t('Exact text sent to {to} (msg.reply).', { to }), signal: card.signal });
744
744
  if (this.#elsewhere(key)) return 'decided';
745
745
  if (!body?.trim()) return 'later';
746
746
  return await this.#confirm(key, () => this.applyQuestion({ project, root, swarm, message_id, mode: 'reply', text: body }), done);
@@ -856,7 +856,7 @@ export class RelayFlow {
856
856
  const id = line(task?.id || '?', 60), controlDir = path.join(hubRoot || '.', 'projects', project, 'ctl');
857
857
  const command = `relay --control-dir ${shellWord(controlDir)} task resume ${shellWord(String(task?.id || '?'))}`;
858
858
  const why = String(detail?.state || '').startsWith('blocked') || detail?.state === 'needs_attention' ? blockLabel(detail.state) : null;
859
- const choice = await this.ui.setupChoice({ ...blockedCard({ task: detail, reason, check, at: at ?? task?.since, why, retry: { command } }), signal: this.signal });
859
+ const choice = await this.ui.setupChoice({ waits: true, ...blockedCard({ task: detail, reason, check, at: at ?? task?.since, why, retry: { command } }), signal: this.signal });
860
860
  if (choice !== 'retry') return 'later';
861
861
  this.ui.notice(t('Retry it yourself in your terminal, exactly: {command}', { command }));
862
862
  await this.record({ action: 'blocked.shown', project, swarm, task: task?.id || null, check: check || null, reason: line(reason || '', 200) });
@@ -876,7 +876,7 @@ export class RelayFlow {
876
876
  const review = await this.#finalReview({ root: root || this.current?.hub_root, project, tasks });
877
877
  await this.state.setSwarm(project, swarm, { finished_at: new Date().toISOString() });
878
878
  await this.record({ action: 'swarm.finished', project, swarm, branch, base_commit: base || null, counts, ok, evidence: evidence.length });
879
- const choice = await this.ui.setupChoice({ ...finishCard({ swarm, objective: info.objective || view?.objective, branch, counts, diffStat: stat, base: base || '?', ok, evidence, review }), signal: this.signal });
879
+ const choice = await this.ui.setupChoice({ waits: true, ...finishCard({ swarm, objective: info.objective || view?.objective, branch, counts, diffStat: stat, base: base || '?', ok, evidence, review }), signal: this.signal });
880
880
  if (choice === 'diff' && base) {
881
881
  const diff = await this.git.diff(repo, base, branch);
882
882
  this.ui.commandResult?.('relay', 'diff', (diff.truncated ? t('Diff of {branch} ({count} more lines not shown; git diff {base}..{branch})', { branch, count: diff.truncated, base: base.slice(0, 8) }) + '\n\n' : '') + diff.text);
@@ -280,7 +280,7 @@ export class InteractiveController {
280
280
  if(state.reason==='authentication' && loginProvider)items.push({id:'login',label:t('Sign in to the selected provider'),description:loginProvider});
281
281
  items.push({id:'choose',label:t('Choose another connection'),description:t('Open the provider and workspace profile selector.')});
282
282
  const reason=CONNECTION_REASON_HINTS[state.reason];
283
- const choice=await this.ui.setupChoice({title:t(state.confirmedUnchanged?'Connection change not applied':'Connection status needs review'),description:t(state.message)+(reason?'\n\n'+t(reason):''),items});
283
+ const choice=await this.ui.setupChoice({waits:true,title:t(state.confirmedUnchanged?'Connection change not applied':'Connection status needs review'),description:t(state.message)+(reason?'\n\n'+t(reason):''),items});
284
284
  if(this.closed)return;
285
285
  if(choice==='retry' && this.connections.canRetry(id))await this.submit(`/${action.command} ${action.argument}`);
286
286
  else if(choice==='login')await this.submit(`/login ${loginProvider}`);
@@ -14,6 +14,7 @@ import { noticeMailbox } from '../plugin-notices.mjs';
14
14
  import { presentResultBlock, presentToolBlock, presentPermission } from './present.mjs';
15
15
  import { normalizeSwarmView, swarmPanelLines, swarmStripLines, swarmActions } from './swarm-panel.mjs';
16
16
  import { formatTurnTiming, measureHumanWait } from '../turn-timing.mjs';
17
+ import { AgentStatus, agentState, statusLabel } from '../agent-status.mjs';
17
18
 
18
19
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
19
20
  const MOUSE_ON = '\x1b[?1000h\x1b[?1006h';
@@ -33,6 +34,7 @@ export class TerminalScreen {
33
34
  start() {
34
35
  if (this.started || this.closed) return this;
35
36
  this.started = true;
37
+ this.agentStatus ||= new AgentStatus({ output: this.output, env: this.env });
36
38
  this.originalRaw = this.input.isRaw || false;
37
39
  this.input.setRawMode?.(true); this.input.resume();
38
40
  this.decoder = new TerminalInput({ onKey: (text, key) => { Promise.resolve(this.handleKey(text, key)).catch(error => this.notice(error.message, true)); }, onMouse: event => this.handleMouse(event), onPaste: text => this.paste(text), onError: error => this.notice(error.message, true) });
@@ -67,20 +69,22 @@ export class TerminalScreen {
67
69
  clearSetup() { this.setup?.finish(null); }
68
70
  // keys: atajos de una letra → id de opción (p. ej. {e:'edit', r:'reject'}), sólo sin filtro.
69
71
  // hint: línea de ayuda propia. Una descripción larga (tarjetas de /relay) se desplaza con PgUp/PgDn.
70
- setupChoice({ title, description = '', items, signal, searchable = false, selected, keys, hint } = {}) {
72
+ // waits: the choice was pushed to the human (a swarm card, a connection review) and Comal waits on the answer; it is
73
+ // reported as a question waiting. A picker the human opened (/model, /provider) is not.
74
+ setupChoice({ title, description = '', items, signal, searchable = false, selected, keys, hint, waits = false } = {}) {
71
75
  if (!Array.isArray(items) || !items.length || items.length > 1024 || items.some(item => typeof item?.id !== 'string' || !item.id || typeof item.label !== 'string') || new Set(items.map(item => item.id)).size !== items.length) throw new Error(t('Choose between 1 and 1024 distinct options.'));
72
76
  const shortcuts = !searchable && keys && typeof keys === 'object' ? Object.fromEntries(Object.entries(keys).filter(([key, id]) => /^[a-z]$/.test(key) && items.some(item => item.id === id))) : null;
73
- return new Promise(resolve => this.beginSetup({ kind: 'choice', title: safeText(title || t('Choose a connection')), description: safeText(description), items: items.map(item => ({ id: item.id, label: safeText(item.label), description: safeText(item.description || ''), badge: safeText(item.badge || '') })), index: Math.max(0,items.findIndex(item=>item.id===selected)), searchable, query: '', keys: shortcuts, hint: hint ? safeText(hint) : null, scroll: 0 }, signal, resolve));
77
+ return new Promise(resolve => this.beginSetup({ kind: 'choice', waits: Boolean(waits), title: safeText(title || t('Choose a connection')), description: safeText(description), items: items.map(item => ({ id: item.id, label: safeText(item.label), description: safeText(item.description || ''), badge: safeText(item.badge || '') })), index: Math.max(0,items.findIndex(item=>item.id===selected)), searchable, query: '', keys: shortcuts, hint: hint ? safeText(hint) : null, scroll: 0 }, signal, resolve));
74
78
  }
75
79
  setupItems() {
76
80
  const state=this.setup;
77
81
  const terms=(state?.query || '').toLocaleLowerCase().split(/\s+/).filter(Boolean);
78
82
  return (state?.items || []).filter(item=>terms.every(term=>`${item.label} ${item.id} ${item.description}`.toLocaleLowerCase().includes(term)));
79
83
  }
80
- setupInput({ title, description = '', value = '', secret = false, signal, validate } = {}) {
84
+ setupInput({ title, description = '', value = '', secret = false, signal, validate, waits = false } = {}) {
81
85
  const clean = safeText(value).replace(/[\r\n\t]+/g, ' ');
82
86
  if (Buffer.byteLength(clean) > 4096) throw new Error(t('Setup values support up to 4096 bytes.'));
83
- return new Promise(resolve => this.beginSetup({ kind: 'input', title: safeText(title || t('Connection settings')), description: safeText(description), editor: new Composer(clean), secret: Boolean(secret), validate }, signal, resolve));
87
+ return new Promise(resolve => this.beginSetup({ kind: 'input', waits: Boolean(waits), title: safeText(title || t('Connection settings')), description: safeText(description), editor: new Composer(clean), secret: Boolean(secret), validate }, signal, resolve));
84
88
  }
85
89
  setupStatus({ title, description = '', signal } = {}) {
86
90
  this.beginSetup({ kind: 'status', title: safeText(title || t('Connecting')), description: safeText(description) }, signal);
@@ -193,6 +197,8 @@ export class TerminalScreen {
193
197
  }
194
198
 
195
199
  schedule() {
200
+ // The state goes out even while an editor owns the terminal (Ctrl+G) or a selection freezes the screen.
201
+ if (!this.closed && (this.suspended || this.selecting)) this.syncAgentStatus();
196
202
  if (this.closed || this.suspended || this.selecting || this.renderTimer) return;
197
203
  this.renderTimer = setTimeout(() => { this.renderTimer = null; this.render(); }, 24);
198
204
  }
@@ -710,6 +716,7 @@ export class TerminalScreen {
710
716
  }
711
717
  render(selectionEntry = false) {
712
718
  if (this.closed || this.suspended || (this.selecting && !selectionEntry)) return;
719
+ this.syncAgentStatus();
713
720
  const columns = Math.max(1, (this.output.columns || 80) - 1), rows = this.output.rows || 24;
714
721
  if (columns < 23 || rows < 10) { this.draw([clip(t("Enlarge the terminal to 24×10"), columns)], null, columns, rows); return; }
715
722
  if (this.setup && !this.permission) { this.renderSetup(columns, rows); return; }
@@ -803,9 +810,18 @@ export class TerminalScreen {
803
810
  this.suspended = true; clearTimeout(this.renderTimer); this.renderTimer = null;
804
811
  this.input.setRawMode?.(this.originalRaw); this.input.pause(); this.output.write(MOUSE_OFF + '\x1b[?2004l\x1b[?25h\x1b[?1049l');
805
812
  }
806
- resume() { this.input.setRawMode?.(true); this.input.resume(); this.suspended = false; this.previous = []; this.output.write('\x1b[?1049h\x1b[?2004h' + MOUSE_ON); this.render(); }
813
+ resume() { this.input.setRawMode?.(true); this.input.resume(); this.suspended = false; this.previous = []; this.agentStatus?.forgetTitle(); this.output.write('\x1b[?1049h\x1b[?2004h' + MOUSE_ON); this.render(); }
814
+ // The terminal title and, inside herdr, the pane's agent state follow what the screen shows (working, a question
815
+ // waiting for the human, or idle). Best-effort: see agent-status.mjs.
816
+ syncAgentStatus() {
817
+ if (!this.agentStatus) return;
818
+ const state = agentState({ busy: this.busy, permission: this.permission, setup: this.setup });
819
+ const message = state !== 'blocked' ? '' : this.permission ? t('Permission: {v0}', { v0: this.permission.name }) : (this.setup?.title || t('Waiting for your answer'));
820
+ this.agentStatus.update({ state, label: statusLabel({ name: this.metadata.name, cwd: this.metadata.cwd }), message, sessionId: this.metadata.sessionId, titles: !this.suspended });
821
+ }
807
822
  close() {
808
823
  if (this.closed) return; this.closed = true;
824
+ this.agentStatus?.close();
809
825
  this.clearSetup();
810
826
  this.permission?.finish(false); clearInterval(this.timer); clearTimeout(this.renderTimer); this.decoder?.close();
811
827
  if (this.onData) this.input.off('data', this.onData); if (this.onResize) this.output.off('resize', this.onResize);