@parallel-cli/parallel 0.4.7 → 0.4.8

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/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to Parallel are documented here.
4
4
 
5
+ ## 0.4.8 - 2026-06-24
6
+
7
+ ### 0.4.8 Changed
8
+
9
+ - Added clearer attached-terminal control with `/stop`, visible stop hints for active agents, and command palette support in attached terminals.
10
+ - Made hidden Hub progress explicit by showing `+N steps` with direct `full /focus aN` and `term /attach aN` shortcuts when rows are truncated.
11
+ - Froze elapsed-time telemetry once agents reach `done`, `error`, or `stopped` so finished agents no longer keep counting.
12
+
13
+ ### 0.4.8 Fixed
14
+
15
+ - Fixed OpenAI-compatible `tool_calls` history failures by recording assistant tool calls and all matching tool results atomically, even when a task completes early or an agent is stopped.
16
+ - Repaired restored conversations with missing tool results before the next model call to prevent 400 errors after interrupted runs.
17
+ - Restored a bounded live activity timeline in attached terminals while preserving append-only native scrollback for final results.
18
+
5
19
  ## 0.4.7 - 2026-06-24
6
20
 
7
21
  ### 0.4.7 Added
package/README.md CHANGED
@@ -167,7 +167,7 @@ Input has three explicit contexts:
167
167
 
168
168
  - Hub: plain text launches a new `/task` agent. Slash suggestions show hub commands and agent arguments autocomplete for `/focus`, `/send`, `/attach`, `/pause`, `/resume`, `/stop`, `/restore`, and `/commit`.
169
169
  - Focus: after `/focus a1`, plain text talks to the focused agent instead of spawning a new one. `/raw` affects this view only.
170
- - Attach: in `parallel attach a1`, the same minimal prompt UI steers the attached agent. `/task`, `/ask`, and `/plan` spawn new agents from that terminal, while `@all ...`, `@a2 ...`, and `/send ...` route instructions through the main session.
170
+ - Attach: in `parallel attach a1`, the same minimal prompt UI steers the attached agent. `/stop` stops the attached agent, `/task`, `/ask`, and `/plan` spawn new agents from that terminal, while `@all ...`, `@a2 ...`, and `/send ...` route instructions through the main session.
171
171
 
172
172
  Use `Name: task` when naming an agent:
173
173
 
@@ -237,6 +237,7 @@ plain text sends a message to this agent
237
237
  /ask Reviewer: is this result safe to merge?
238
238
  /plan Migration: prepare a migration plan
239
239
  /review all before commit
240
+ /stop
240
241
  /raw
241
242
  /quit
242
243
  ```
@@ -225,6 +225,40 @@ export class Agent {
225
225
  await new Promise((r) => setTimeout(r, 300));
226
226
  }
227
227
  }
228
+ repairToolCallHistory() {
229
+ const repaired = [];
230
+ for (let i = 0; i < this.history.length; i++) {
231
+ const msg = this.history[i];
232
+ if (msg.role === 'tool') {
233
+ // Orphan tool messages make OpenAI-compatible APIs reject the whole
234
+ // request. Valid tool messages are consumed immediately after their
235
+ // assistant tool_calls block below.
236
+ continue;
237
+ }
238
+ repaired.push(this.history[i]);
239
+ const toolCalls = msg.role === 'assistant' && Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
240
+ if (toolCalls.length === 0)
241
+ continue;
242
+ const seen = new Set();
243
+ while (i + 1 < this.history.length && this.history[i + 1].role === 'tool') {
244
+ const toolMsg = this.history[++i];
245
+ if (toolMsg.tool_call_id)
246
+ seen.add(String(toolMsg.tool_call_id));
247
+ repaired.push(toolMsg);
248
+ }
249
+ for (const tc of toolCalls) {
250
+ const id = String(tc.id ?? '');
251
+ if (!id || seen.has(id))
252
+ continue;
253
+ repaired.push({
254
+ role: 'tool',
255
+ tool_call_id: id,
256
+ content: 'Skipped: missing tool result repaired before the next model call.',
257
+ });
258
+ }
259
+ }
260
+ this.history = repaired;
261
+ }
228
262
  updatePerf(delta) {
229
263
  const current = this.board.agents.get(this.id)?.perf ?? EMPTY_PERF;
230
264
  this.board.updateAgent(this.id, {
@@ -340,6 +374,7 @@ export class Agent {
340
374
  if (this.stopped)
341
375
  break;
342
376
  }
377
+ this.repairToolCallHistory();
343
378
  const messages = [
344
379
  ...this.history,
345
380
  { role: 'user', content: live.text },
@@ -385,15 +420,15 @@ export class Agent {
385
420
  });
386
421
  }
387
422
  const msg = res.message;
388
- // Persist this round into history (live context is NOT kept — rebuilt fresh each turn).
389
- this.record({ role: 'user', content: '[real-time state consulted]' });
390
- this.record(msg);
391
423
  if (msg.content && msg.content.trim()) {
392
424
  // "✻" marks thinking/commentary steps — visually distinct from tool lines.
393
425
  this.board.log(this.id, 'llm', `✻ ${msg.content.trim().slice(0, 500)}`);
394
426
  }
395
427
  const toolCalls = msg.tool_calls ?? [];
396
428
  if (toolCalls.length === 0) {
429
+ // Persist this round into history (live context is NOT kept — rebuilt fresh each turn).
430
+ this.record({ role: 'user', content: '[real-time state consulted]' });
431
+ this.record(msg);
397
432
  this.record({
398
433
  role: 'user',
399
434
  content: 'No tool was called. If your task is finished and verified, call task_complete. Otherwise, continue with tool calls.',
@@ -402,21 +437,33 @@ export class Agent {
402
437
  }
403
438
  this.board.setAgentState(this.id, 'working');
404
439
  let completed = false;
405
- for (const tc of toolCalls) {
406
- if (this.stopped)
440
+ const toolResults = [];
441
+ const postToolMessages = [];
442
+ const addToolResult = (toolCallId, content) => {
443
+ toolResults.push({ role: 'tool', tool_call_id: toolCallId, content });
444
+ };
445
+ const addSkippedToolResults = (startIndex, content) => {
446
+ for (const remaining of toolCalls.slice(startIndex)) {
447
+ if (remaining.id)
448
+ addToolResult(remaining.id, content);
449
+ }
450
+ };
451
+ for (let i = 0; i < toolCalls.length; i++) {
452
+ const tc = toolCalls[i];
453
+ if (this.stopped) {
454
+ addSkippedToolResults(i, 'Skipped: the agent was stopped before this tool call executed.');
407
455
  break;
408
- if (tc.type !== 'function')
456
+ }
457
+ if (tc.type !== 'function') {
458
+ addToolResult(tc.id, 'ERROR: unsupported tool call type.');
409
459
  continue;
460
+ }
410
461
  let args = {};
411
462
  try {
412
463
  args = tc.function.arguments ? JSON.parse(tc.function.arguments) : {};
413
464
  }
414
465
  catch {
415
- this.record({
416
- role: 'tool',
417
- tool_call_id: tc.id,
418
- content: 'ERROR: invalid JSON arguments.',
419
- });
466
+ addToolResult(tc.id, 'ERROR: invalid JSON arguments.');
420
467
  continue;
421
468
  }
422
469
  const label = this.describeCall(tc.function.name, args);
@@ -428,7 +475,13 @@ export class Agent {
428
475
  }
429
476
  this.board.updateAgent(this.id, { currentAction: label.slice(0, 80) });
430
477
  const shellStartedAt = tc.function.name === 'run_command' ? Date.now() : 0;
431
- const result = await this.executor.execute(tc.function.name, args);
478
+ let result;
479
+ try {
480
+ result = await this.executor.execute(tc.function.name, args);
481
+ }
482
+ catch (err) {
483
+ result = `ERROR: ${err?.message ?? String(err)}`;
484
+ }
432
485
  const shellMs = shellStartedAt ? Date.now() - shellStartedAt : 0;
433
486
  const readOnlyShell = tc.function.name === 'run_command' && isReadOnlyShell(String(args.command ?? ''));
434
487
  this.updatePerf({
@@ -440,7 +493,7 @@ export class Agent {
440
493
  if (readOnlyShell) {
441
494
  this.readOnlyShellStreak++;
442
495
  if (this.readOnlyShellStreak >= 3) {
443
- this.record({
496
+ postToolMessages.push({
444
497
  role: 'user',
445
498
  content: '[PERFORMANCE CORRECTION] You are using several read-only shell micro-commands. Batch the next inspection with read_many/inspect_project or a single labelled shell command, then continue.',
446
499
  });
@@ -465,11 +518,21 @@ export class Agent {
465
518
  // is rendered as the agent's recap) — no duplicated walls of text.
466
519
  const headline = summary.split('\n').find((l) => l.trim())?.trim() ?? 'Task complete.';
467
520
  this.board.addNote(this.name, 'all', `✅ ${headline.slice(0, 160)}`);
468
- this.record({ role: 'tool', tool_call_id: tc.id, content: 'OK, task closed.' });
521
+ addToolResult(tc.id, 'OK, task closed.');
522
+ addSkippedToolResults(i + 1, 'Skipped: task_complete closed the task before this tool call executed.');
469
523
  break;
470
524
  }
471
- this.record({ role: 'tool', tool_call_id: tc.id, content: result });
525
+ addToolResult(tc.id, result);
472
526
  }
527
+ // OpenAI-compatible APIs require assistant tool_calls to be followed
528
+ // immediately by one tool result per tool_call_id. Keep this block
529
+ // atomic so aborted/skipped tools never poison a later turn or restore.
530
+ this.record({ role: 'user', content: '[real-time state consulted]' });
531
+ this.record(msg);
532
+ for (const toolResult of toolResults)
533
+ this.record(toolResult);
534
+ for (const postToolMessage of postToolMessages)
535
+ this.record(postToolMessage);
473
536
  if (completed) {
474
537
  this.board.setAgentState(this.id, 'done', 'done ✅');
475
538
  return;
package/dist/commands.js CHANGED
@@ -70,6 +70,7 @@ const COMMAND_PALETTE_PRIORITY = [
70
70
  '/send',
71
71
  '/focus',
72
72
  '/attach',
73
+ '/stop',
73
74
  '/agents',
74
75
  '/board',
75
76
  '/diff',
@@ -647,11 +647,14 @@ export class Controller extends EventEmitter {
647
647
  tokensIn: a.tokensIn,
648
648
  tokensOut: a.tokensOut,
649
649
  cost: a.cost,
650
+ endedAt: a.endedAt,
650
651
  providerName,
651
652
  model: a.model,
652
653
  specialist: a.specialist,
653
654
  claims: a.claims,
654
655
  ctxPct: a.ctxPct,
656
+ progressSteps: a.progressSteps,
657
+ perf: a.perf,
655
658
  conversation: this.conversationFiles.get(a.id),
656
659
  })),
657
660
  notes: this.board.notes.slice(-200),
@@ -58,8 +58,14 @@ export class Blackboard extends EventEmitter {
58
58
  if (action !== undefined)
59
59
  a.currentAction = action;
60
60
  // A finished agent no longer holds any declared work area.
61
- if (state === 'done' || state === 'stopped' || state === 'error')
61
+ if (state === 'done' || state === 'stopped' || state === 'error') {
62
62
  a.claims = undefined;
63
+ if (!a.endedAt)
64
+ a.endedAt = Date.now();
65
+ }
66
+ else {
67
+ a.endedAt = undefined;
68
+ }
63
69
  if (prev !== state)
64
70
  this.emit('agent-event', { type: 'state', id, state, prev });
65
71
  this.touch();
package/dist/server.js CHANGED
@@ -118,6 +118,15 @@ export function startSessionServer(ctl) {
118
118
  else
119
119
  ctl.sendToAgent(target, text);
120
120
  }
121
+ else if (msg.type === 'stop' && typeof msg.target === 'string') {
122
+ const target = msg.target.trim();
123
+ if (!target)
124
+ continue;
125
+ if (target.toLowerCase() === 'all')
126
+ ctl.stopAll();
127
+ else
128
+ ctl.stopAgent(target);
129
+ }
121
130
  else if (msg.type === 'spawn' && typeof msg.text === 'string') {
122
131
  // Agent N+1 can be launched from ANY terminal of the session —
123
132
  // its own dedicated terminal then opens automatically.
@@ -29,7 +29,8 @@ export function cleanHubSummary(text) {
29
29
  export function formatAgentTelemetry(agent) {
30
30
  const ctx = agent.ctxPct !== undefined ? ` · ${agent.ctxPct}% ctx` : '';
31
31
  const perf = agent.perf ? ` · ${agent.perf.modelTurns}t/${agent.perf.toolCalls} tools` : '';
32
- return `${elapsed(agent.startedAt)}${ctx}${perf} · ${agent.cost === null ? '$-' : fmtCost(agent.cost)}`;
32
+ const runtime = agent.endedAt ? `ended ${elapsed(agent.startedAt, agent.endedAt)}` : elapsed(agent.startedAt);
33
+ return `${runtime}${ctx}${perf} · ${agent.cost === null ? '$-' : fmtCost(agent.cost)}`;
33
34
  }
34
35
  function firstSectionLine(text, labels) {
35
36
  const lines = text.replace(/\r/g, '').split('\n');
@@ -106,22 +107,29 @@ export function modeBadge(mode) {
106
107
  return { label: 'PLAN', color: MODE.plan };
107
108
  return { label: 'TASK', color: MODE.task };
108
109
  }
110
+ export function hiddenProgressCount(agent, max) {
111
+ return Math.max(0, (agent.progressSteps?.length ?? 0) - max);
112
+ }
109
113
  function agentDisplayName(agent) {
110
114
  return agent.alias && agent.alias !== agent.name ? `${agent.alias} ${agent.name}` : agent.alias || agent.name;
111
115
  }
112
- export function ProgressSteps({ agent, max = 4, cols = 100 }) {
116
+ export function ProgressSteps({ agent, max = 4, cols = 100, showRemaining = false, }) {
113
117
  const steps = agent.progressSteps?.slice(0, max) ?? [];
118
+ const total = agent.progressSteps?.length ?? 0;
114
119
  if (steps.length === 0)
115
120
  return null;
116
121
  const textMax = Math.max(20, cols - 8);
117
- return (_jsx(Box, { flexDirection: "column", children: steps.map((step, i) => {
118
- const active = step.status === 'active';
119
- const done = step.status === 'done';
120
- return (_jsxs(Text, { color: done ? UI.ok : active ? COLOR.cream : UI.muted, wrap: "truncate-end", children: [_jsxs(Text, { color: done ? UI.ok : active ? COLOR.cream : UI.muted, children: [done ? MARK.done : active ? MARK.active : MARK.idle, " "] }), truncate(step.text, textMax)] }, `${i}-${step.text}`));
121
- }) }));
122
+ const remaining = hiddenProgressCount(agent, max);
123
+ const ref = agent.alias || agent.name;
124
+ return (_jsxs(Box, { flexDirection: "column", children: [steps.map((step, i) => {
125
+ const active = step.status === 'active';
126
+ const done = step.status === 'done';
127
+ return (_jsxs(Text, { color: done ? UI.ok : active ? COLOR.cream : UI.muted, wrap: "truncate-end", children: [_jsxs(Text, { color: done ? UI.ok : active ? COLOR.cream : UI.muted, children: [done ? MARK.done : active ? MARK.active : MARK.idle, " "] }), truncate(step.text, textMax)] }, `${i}-${step.text}`));
128
+ }), showRemaining && remaining > 0 ? (_jsxs(Text, { color: COLOR.creamMuted, wrap: "truncate-end", children: ["+", remaining, " steps \u00B7 full /focus ", ref, " \u00B7 term /attach ", ref] })) : null] }));
122
129
  }
123
130
  export function AgentRow({ agent, logs, cols, }) {
124
131
  const meta = STATE_META[agent.state];
132
+ const terminal = agent.state === 'done' || agent.state === 'error' || agent.state === 'stopped';
125
133
  // ── State transition pulse (Phase 5) ──
126
134
  const prevState = useRef(agent.state);
127
135
  const [pulse, setPulse] = useState(false);
@@ -137,7 +145,11 @@ export function AgentRow({ agent, logs, cols, }) {
137
145
  const pulseColor = pulse ? 'whiteBright' : null;
138
146
  const name = agentDisplayName(agent);
139
147
  const mode = modeBadge(agent.mode);
140
- const quickActions = `full /focus ${agent.alias || agent.name} · term /attach ${agent.alias || agent.name}`;
148
+ const quickActions = terminal
149
+ ? agent.state === 'error'
150
+ ? `full /focus ${agent.alias || agent.name} · term /attach ${agent.alias || agent.name} · clear /clear`
151
+ : `full /focus ${agent.alias || agent.name} · term /attach ${agent.alias || agent.name}`
152
+ : `stop /stop ${agent.alias || agent.name} · full /focus ${agent.alias || agent.name} · term /attach ${agent.alias || agent.name}`;
141
153
  const actionBudget = Math.min(44, quickActions.length + 2);
142
154
  const taskMax = Math.max(10, cols - 18 - actionBudget);
143
155
  const line2Max = Math.max(10, cols - 2);
@@ -154,7 +166,7 @@ export function AgentRow({ agent, logs, cols, }) {
154
166
  else if (claims) {
155
167
  line2 = { text: claims, color: UI.warn };
156
168
  }
157
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 0, paddingLeft: 1, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { wrap: "truncate-end", children: [SPINNER_STATES.has(agent.state) ? (_jsx(Spinner, { color: pulseColor ?? spinnerColor(agent.state) })) : (_jsx(Text, { color: pulseColor ?? meta.color, bold: true, children: meta.mark })), _jsx(Text, { children: " " }), _jsx(Text, { color: agent.color, bold: true, children: name }), _jsxs(Text, { color: mode.color, children: [" [", mode.label, "]"] }), specialist ? _jsx(Text, { color: UI.note, children: specialist }) : null, _jsxs(Text, { color: UI.text, children: [" ", truncate(agent.task, taskMax)] })] }), _jsx(Text, { color: UI.muted, wrap: "truncate-end", children: truncate(quickActions, actionBudget) })] }), summary.length > 0 ? (_jsx(Box, { flexDirection: "column", children: summary.map((line, i) => (_jsxs(Box, { flexDirection: "row", justifyContent: i === 0 ? 'space-between' : undefined, children: [_jsxs(Text, { color: COLOR.cream, wrap: "truncate-end", children: [_jsx(Text, { color: COLOR.cream, children: "\u2022 " }), line] }), i === 0 ? _jsx(Text, { color: UI.muted, children: telemetry }) : null] }, `${i}-${line}`))) })) : line2 ? (_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(Text, { color: line2.color, wrap: "truncate-end", children: line2.text }), _jsx(Text, { color: UI.muted, children: telemetry })] })) : null, !agent.lastResult ? _jsx(ProgressSteps, { agent: agent, max: 3, cols: line2Max }) : null] }));
169
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 0, paddingLeft: 1, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { wrap: "truncate-end", children: [SPINNER_STATES.has(agent.state) ? (_jsx(Spinner, { color: pulseColor ?? spinnerColor(agent.state) })) : (_jsx(Text, { color: pulseColor ?? meta.color, bold: true, children: meta.mark })), _jsx(Text, { children: " " }), _jsx(Text, { color: agent.color, bold: true, children: name }), _jsxs(Text, { color: mode.color, children: [" [", mode.label, "]"] }), specialist ? _jsx(Text, { color: UI.note, children: specialist }) : null, _jsxs(Text, { color: UI.text, children: [" ", truncate(agent.task, taskMax)] })] }), _jsx(Text, { color: UI.muted, wrap: "truncate-end", children: truncate(quickActions, actionBudget) })] }), summary.length > 0 ? (_jsx(Box, { flexDirection: "column", children: summary.map((line, i) => (_jsxs(Box, { flexDirection: "row", justifyContent: i === 0 ? 'space-between' : undefined, children: [_jsxs(Text, { color: COLOR.cream, wrap: "truncate-end", children: [_jsx(Text, { color: COLOR.cream, children: "\u2022 " }), line] }), i === 0 ? _jsx(Text, { color: UI.muted, children: telemetry }) : null] }, `${i}-${line}`))) })) : line2 ? (_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(Text, { color: line2.color, wrap: "truncate-end", children: line2.text }), _jsx(Text, { color: UI.muted, children: telemetry })] })) : null, !agent.lastResult ? _jsx(ProgressSteps, { agent: agent, max: 3, cols: line2Max, showRemaining: true }) : null] }));
158
170
  }
159
171
  export function AgentTranscript({ agent, logs, raw = false, scrolled = 0, cols = 100, }) {
160
172
  const meta = STATE_META[agent.state];
package/dist/ui/App.js CHANGED
@@ -687,7 +687,9 @@ function AgentHub({ agents, ctl, cols, scroll, visibleRows, }) {
687
687
  }
688
688
  const needsSeparator = rows.length > 0;
689
689
  const summaryLines = agent.lastResult ? Math.min(4, Math.max(1, agent.lastResult.split('\n').filter((l) => l.trim()).length)) : 0;
690
- const stepLines = !agent.lastResult && agent.progressSteps && agent.progressSteps.length > 0 ? Math.min(3, agent.progressSteps.length) : 0;
690
+ const stepLines = !agent.lastResult && agent.progressSteps && agent.progressSteps.length > 0
691
+ ? Math.min(3, agent.progressSteps.length) + (agent.progressSteps.length > 3 ? 1 : 0)
692
+ : 0;
691
693
  const agentLines = 1 + Math.max(summaryLines, agent.currentAction || agent.claims?.length ? 1 : 0) + stepLines;
692
694
  const neededLines = agentLines + (needsSeparator ? 1 : 0);
693
695
  if (renderedLines + neededLines > visibleRows) {
@@ -23,6 +23,9 @@ export function parseAttachCommand(text) {
23
23
  return { type: 'detach' };
24
24
  if (v === '/raw')
25
25
  return { type: 'raw' };
26
+ const stop = v.match(/^\/stop(?:\s+(\S+))?$/s);
27
+ if (stop)
28
+ return { type: 'stop', target: stop[1]?.trim() };
26
29
  const at = v.match(/^@(\S+)\s+(.+)$/s);
27
30
  if (at)
28
31
  return { type: 'send', target: at[1], text: at[2].trim() };
@@ -47,7 +50,8 @@ export function parseAttachCommand(text) {
47
50
  export function formatAttachFooter(info) {
48
51
  if (!info)
49
52
  return 'Waiting for agent · /quit';
50
- return `${middleTruncate(info.model, 28)} · ${formatAgentTelemetry(info)} · plain text steers · /task new · /quit`;
53
+ const control = ['thinking', 'working', 'listening', 'waiting', 'paused'].includes(info.state) ? ' · /stop' : '';
54
+ return `${middleTruncate(info.model, 28)} · ${formatAgentTelemetry(info)} · plain text steers${control} · /task new · /quit`;
51
55
  }
52
56
  function AttachStaticLine({ item, raw }) {
53
57
  if (raw) {
@@ -177,6 +181,10 @@ export function AttachApp({ agentRef, sock }) {
177
181
  setRaw((r) => !r);
178
182
  return;
179
183
  }
184
+ if (cmd.type === 'stop') {
185
+ wire({ type: 'stop', target: cmd.target || agentRef });
186
+ return;
187
+ }
180
188
  // /task|/ask|/plan|/review <text> — launch agent N+1 from this terminal.
181
189
  if (cmd.type === 'spawn') {
182
190
  wire({ type: 'spawn', text: cmd.text, mode: cmd.mode });
@@ -198,6 +206,7 @@ export function AttachApp({ agentRef, sock }) {
198
206
  const maxTimelineScroll = Math.max(0, logs.length - timelineVisibleLogs);
199
207
  const clampedTimelineScroll = Math.min(timelineScroll, maxTimelineScroll);
200
208
  const timelineWindow = logs.slice(Math.max(0, logs.length - timelineVisibleLogs - clampedTimelineScroll), logs.length - clampedTimelineScroll);
209
+ const liveTimelineLogs = logs.slice(-Math.max(6, Math.min(14, Math.floor((stdout?.rows ?? 30) / 2))));
201
210
  useEffect(() => {
202
211
  if (timelineFollowTail)
203
212
  setTimelineScroll(0);
@@ -227,11 +236,11 @@ export function AttachApp({ agentRef, sock }) {
227
236
  return (_jsxs(Box, { flexDirection: "column", children: [!raw ? (_jsx(Static, { items: launchCards, children: (item) => _jsx(AttachLaunchHeader, { item: item }, item.key) })) : null, _jsx(Static, { items: staticLines, children: (item) => (_jsx(AttachStaticLine, { item: item, raw: raw }, item.key)) }), !raw ? (_jsx(Static, { items: resultCards, children: (item) => _jsx(AttachResultCard, { item: item }, item.key) })) : null, !raw && staticLines.length > 0 ? _jsx(Text, { color: UI.muted, children: '─'.repeat(Math.min(Math.max(20, (stdout?.columns ?? 100) - 4), 80)) }) : null, (busy || terminal) && info && st && !interacting ? (
228
237
  /* While running, keep the native terminal scrollback stable: activity is
229
238
  * appended once above via <Static>, and this live region stays tiny. */
230
- _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: info.color, bold: true, children: info.alias || info.name }), ' ', _jsxs(Text, { color: modeBadge(info.mode).color, children: ["[", modeBadge(info.mode).label, "]"] }), ' ', _jsxs(Text, { color: st.color, bold: true, children: [st.mark, " ", st.label] }), _jsxs(Text, { color: UI.muted, children: [' ', "\u00B7 ", elapsed(info.startedAt), " \u00B7 ", info.steps, " st \u00B7", ' ', Math.round((info.tokensIn + info.tokensOut) / 1000), "k \u00B7", ' '] }), _jsx(Text, { color: UI.ok, children: info.cost === null ? '$-' : fmtCost(info.cost) })] }), info.currentAction ? (_jsxs(Text, { color: info.color, wrap: "truncate-end", children: ["Current ", truncate(info.currentAction, 120)] })) : null, _jsx(ProgressSteps, { agent: info, max: 6, cols: process.stdout.columns || 100 }), terminal && info.lastResult ? (_jsx(Text, { color: COLOR.creamMuted, children: "Result was appended above; native mouse scroll stays available." })) : null, others.length > 0 ? (_jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: ["Others ", ' ', others
239
+ _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: info.color, bold: true, children: info.alias || info.name }), ' ', _jsxs(Text, { color: modeBadge(info.mode).color, children: ["[", modeBadge(info.mode).label, "]"] }), ' ', _jsxs(Text, { color: st.color, bold: true, children: [st.mark, " ", st.label] }), _jsxs(Text, { color: UI.muted, children: [' ', "\u00B7 ", elapsed(info.startedAt, info.endedAt), " \u00B7 ", info.steps, " st \u00B7", ' ', Math.round((info.tokensIn + info.tokensOut) / 1000), "k \u00B7", ' '] }), _jsx(Text, { color: UI.ok, children: info.cost === null ? '$-' : fmtCost(info.cost) })] }), info.currentAction ? (_jsxs(Text, { color: info.color, wrap: "truncate-end", children: ["Current ", truncate(info.currentAction, 120)] })) : null, _jsx(ProgressSteps, { agent: info, max: 6, cols: process.stdout.columns || 100 }), busy && timelineFollowTail && liveTimelineLogs.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: UI.muted, bold: true, children: "Live activity" }), _jsx(Timeline, { logs: liveTimelineLogs, cols: process.stdout.columns || 100 })] })) : null, terminal && info.lastResult ? (_jsx(Text, { color: COLOR.creamMuted, children: "Result was appended above; native mouse scroll stays available." })) : null, others.length > 0 ? (_jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: ["Others ", ' ', others
231
240
  .map((o) => `${o.name} [${stateLabel(o.state)}] ${truncate(o.currentAction || o.task, 40)}`)
232
241
  .join(' · ')] })) : null, !timelineFollowTail ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: UI.warn, children: "Viewing older activity \u00B7 \u2193/PgDn to latest" }), _jsx(Timeline, { logs: timelineWindow, cols: process.stdout.columns || 100 })] })) : null] })) : (
233
242
  /* FULL panel for idle/waiting/interactions — terminal states stay compact. */
234
- _jsxs(Box, { borderStyle: "single", borderColor: info?.color ?? 'gray', flexDirection: "column", paddingX: 1, marginTop: 1, children: [info && st ? (_jsxs(_Fragment, { children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { children: [banner, _jsxs(Text, { color: modeBadge(info.mode).color, children: [" [", modeBadge(info.mode).label, "]"] }), _jsxs(Text, { color: st.color, bold: true, children: [' ', st.mark, " ", st.label] })] }), _jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: [middleTruncate(info.model, 18), " \u00B7 ", elapsed(info.startedAt), " \u00B7 ", info.steps, " st \u00B7", ' ', Math.round((info.tokensIn + info.tokensOut) / 1000), "k \u00B7", ' ', info.ctxPct !== undefined ? (_jsxs(Text, { color: info.ctxPct >= 90 ? UI.danger : info.ctxPct >= 70 ? UI.warn : UI.muted, children: [info.ctxPct, "% \u00B7", ' '] })) : null, _jsx(Text, { color: UI.ok, children: info.cost === null ? '$-' : fmtCost(info.cost) })] })] }), _jsxs(Text, { color: UI.muted, wrap: "wrap", children: ["Task ", _jsx(Text, { color: UI.text, children: info.task })] }), info.currentAction ? (_jsxs(Text, { color: info.color, wrap: "truncate-end", children: ["Current ", truncate(info.currentAction, 160)] })) : null, _jsx(ProgressSteps, { agent: info, max: 6, cols: process.stdout.columns || 100 }), others.length > 0 ? (
243
+ _jsxs(Box, { borderStyle: "single", borderColor: info?.color ?? 'gray', flexDirection: "column", paddingX: 1, marginTop: 1, children: [info && st ? (_jsxs(_Fragment, { children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { children: [banner, _jsxs(Text, { color: modeBadge(info.mode).color, children: [" [", modeBadge(info.mode).label, "]"] }), _jsxs(Text, { color: st.color, bold: true, children: [' ', st.mark, " ", st.label] })] }), _jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: [middleTruncate(info.model, 18), " \u00B7 ", elapsed(info.startedAt, info.endedAt), " \u00B7 ", info.steps, " st \u00B7", ' ', Math.round((info.tokensIn + info.tokensOut) / 1000), "k \u00B7", ' ', info.ctxPct !== undefined ? (_jsxs(Text, { color: info.ctxPct >= 90 ? UI.danger : info.ctxPct >= 70 ? UI.warn : UI.muted, children: [info.ctxPct, "% \u00B7", ' '] })) : null, _jsx(Text, { color: UI.ok, children: info.cost === null ? '$-' : fmtCost(info.cost) })] })] }), _jsxs(Text, { color: UI.muted, wrap: "wrap", children: ["Task ", _jsx(Text, { color: UI.text, children: info.task })] }), info.currentAction ? (_jsxs(Text, { color: info.color, wrap: "truncate-end", children: ["Current ", truncate(info.currentAction, 160)] })) : null, _jsx(ProgressSteps, { agent: info, max: 6, cols: process.stdout.columns || 100 }), others.length > 0 ? (
235
244
  // The session's shared awareness, visible here too: what the
236
245
  // OTHER agents are doing right now (live, same feed the agents get).
237
246
  _jsxs(Text, { color: UI.muted, wrap: "truncate-end", children: ["Others ", ' ', others
@@ -43,7 +43,7 @@ export function bestCommandCompletion(value) {
43
43
  export function commandNamesForContext(context) {
44
44
  if (context !== 'attach')
45
45
  return undefined;
46
- return ['/ask', '/a', '/task', '/t', '/plan', '/p', '/review', '/send', '/raw', '/quit', '/exit', '/detach'];
46
+ return ['/ask', '/a', '/task', '/t', '/plan', '/p', '/review', '/send', '/stop', '/raw', '/quit', '/exit', '/detach'];
47
47
  }
48
48
  export function agentArgCommand(value) {
49
49
  const m = value.match(/^(\/\S+)\s+([^\s]*)$/);
package/dist/ui/theme.js CHANGED
@@ -15,8 +15,8 @@ export const STATE_LABEL = {
15
15
  export function stateLabel(state) {
16
16
  return t(STATE_LABEL[state].labelKey);
17
17
  }
18
- export function elapsed(since) {
19
- const s = Math.floor((Date.now() - since) / 1000);
18
+ export function elapsed(since, until = Date.now()) {
19
+ const s = Math.max(0, Math.floor((until - since) / 1000));
20
20
  if (s < 60)
21
21
  return `${s}s`;
22
22
  const m = Math.floor(s / 60);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parallel-cli/parallel",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "description": "Real-time coding agents that work like a live team on one shared repository.",
5
5
  "keywords": [
6
6
  "cli",