@dotdrelle/wiki-manager 0.8.2 → 0.10.4

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.
@@ -4,6 +4,7 @@ import { DatabaseSync } from 'node:sqlite';
4
4
  import { applyAgentProjectionToSession, dispatchAgentEvent, reduceAgentEvents } from '../core/agentEvents.js';
5
5
  import { defaultRuntimeStateDir } from '../core/env.js';
6
6
  import { projectQueue } from '../core/jobQueue.js';
7
+ import { projectWorkflow } from '../core/workflow.js';
7
8
 
8
9
  export { defaultRuntimeStateDir };
9
10
 
@@ -35,6 +36,7 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
35
36
  type TEXT NOT NULL,
36
37
  run_id TEXT,
37
38
  turn_id TEXT,
39
+ task_id TEXT,
38
40
  workspace TEXT,
39
41
  origin TEXT,
40
42
  payload TEXT NOT NULL
@@ -69,6 +71,7 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
69
71
  `);
70
72
  ensureColumn(db, 'events', 'sequence', 'INTEGER');
71
73
  ensureColumn(db, 'events', 'workspace', 'TEXT');
74
+ ensureColumn(db, 'events', 'task_id', 'TEXT');
72
75
  ensureColumn(db, 'runs', 'workspace', 'TEXT');
73
76
  backfillEventSequence(db);
74
77
  db.exec('CREATE INDEX IF NOT EXISTS idx_events_workspace ON events(workspace)');
@@ -78,17 +81,17 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
78
81
  let lastEventSequence = null;
79
82
 
80
83
  const insertEvent = db.prepare(`
81
- INSERT OR IGNORE INTO events (sequence, id, ts, type, run_id, turn_id, workspace, origin, payload)
82
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
84
+ INSERT OR IGNORE INTO events (sequence, id, ts, type, run_id, turn_id, task_id, workspace, origin, payload)
85
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
83
86
  `);
84
87
  const nextEventSequenceStatement = db.prepare('SELECT COALESCE(MAX(sequence), 0) + 1 AS next_sequence FROM events');
85
88
  const listEventsStatement = db.prepare(`
86
- SELECT sequence, id, ts, type, run_id, turn_id, workspace, origin, payload
89
+ SELECT sequence, id, ts, type, run_id, turn_id, task_id, workspace, origin, payload
87
90
  FROM events
88
91
  ORDER BY sequence ASC
89
92
  `);
90
93
  const listEventsByWorkspaceStatement = db.prepare(`
91
- SELECT sequence, id, ts, type, run_id, turn_id, workspace, origin, payload
94
+ SELECT sequence, id, ts, type, run_id, turn_id, task_id, workspace, origin, payload
92
95
  FROM events
93
96
  WHERE workspace = ?
94
97
  ORDER BY sequence ASC
@@ -195,6 +198,7 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
195
198
  event.type,
196
199
  event.runId ?? null,
197
200
  event.turnId ?? null,
201
+ event.taskId ?? event.payload?.taskId ?? null,
198
202
  ws,
199
203
  event.origin ?? null,
200
204
  JSON.stringify(event.payload ?? {}),
@@ -240,6 +244,12 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
240
244
  return rows.map(rowToEvent);
241
245
  }
242
246
 
247
+ function listAuditTrail({ workspace = null, runId = null } = {}) {
248
+ return listEvents({ workspace })
249
+ .filter((event) => !runId || event.runId === runId || event.payload?.runId === runId)
250
+ .map(eventToAuditEntry);
251
+ }
252
+
243
253
  function listRuns({ workspace = null } = {}) {
244
254
  const rows = workspace
245
255
  ? listRunsByWorkspaceStatement.all(workspace)
@@ -362,12 +372,22 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
362
372
  const events = session?.agentProjection ? null : listEvents({ workspace });
363
373
  const projection = session?.agentProjection ?? reduceAgentEvents(events);
364
374
  const rawQueue = session?.queueStore?.list() ?? listQueue({ workspace });
365
- return {
375
+ const queue = projectQueue(projection.plan, rawQueue, { workspace });
376
+ const controlQueue = Array.isArray(projection.controlQueue)
377
+ ? projection.controlQueue.filter((item) => !workspace || item.workspace === workspace || !item.workspace).map((item) => ({ ...item }))
378
+ : [];
379
+ const baseState = {
366
380
  ...projection,
367
- runs: listRuns({ workspace }),
368
- queue: projectQueue(projection.plan, rawQueue, { workspace }),
381
+ queue,
382
+ controlQueue,
369
383
  eventsCursor: session?.agentEvents?.at(-1)?.sequence ?? events?.at(-1)?.sequence ?? lastEventSequence,
370
384
  };
385
+ const runs = listRuns({ workspace });
386
+ return {
387
+ ...baseState,
388
+ runs,
389
+ workflow: projectWorkflow({ ...baseState, runs, workspace }, events ?? session?.agentEvents ?? []),
390
+ };
371
391
  }
372
392
 
373
393
  function hydrateSession(session, { workspace = null } = {}) {
@@ -388,6 +408,7 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
388
408
  persistEvent,
389
409
  persistRun,
390
410
  listEvents,
411
+ listAuditTrail,
391
412
  listRuns,
392
413
  listRecoverableRuns,
393
414
  listRecoverableWorkspaces,
@@ -411,11 +432,42 @@ function rowToEvent(row) {
411
432
  origin: row.origin ?? 'system',
412
433
  runId: row.run_id ?? null,
413
434
  turnId: row.turn_id ?? null,
435
+ taskId: row.task_id ?? null,
414
436
  workspace: row.workspace ?? null,
415
437
  payload: row.payload ? JSON.parse(row.payload) : {},
416
438
  };
417
439
  }
418
440
 
441
+ function eventToAuditEntry(event) {
442
+ const payload = event.payload ?? {};
443
+ const activity = payload.activity ?? {};
444
+ return {
445
+ sequence: event.sequence ?? null,
446
+ ts: event.ts,
447
+ type: event.type,
448
+ runId: event.runId ?? payload.runId ?? null,
449
+ turnId: event.turnId ?? payload.turnId ?? null,
450
+ taskId: event.taskId ?? payload.taskId ?? activity.taskId ?? null,
451
+ activityId: payload.activityId ?? activity.id ?? activity.key ?? null,
452
+ toolCallId: payload.toolCallId ?? payload.callId ?? null,
453
+ workspace: event.workspace ?? payload.workspace ?? null,
454
+ caller: event.origin ?? payload.caller ?? 'system',
455
+ status: payload.status ?? activity.status ?? null,
456
+ tool: payload.tool ?? payload.name ?? activity.tool ?? null,
457
+ summary: auditSummary(event),
458
+ };
459
+ }
460
+
461
+ function auditSummary(event) {
462
+ const payload = event.payload ?? {};
463
+ if (typeof payload.message === 'string') return payload.message;
464
+ if (typeof payload.summary === 'string') return payload.summary;
465
+ if (typeof payload.input === 'string') return payload.input;
466
+ if (typeof payload.content === 'string') return payload.content.slice(0, 240);
467
+ if (payload.activity?.label) return payload.activity.label;
468
+ return event.type;
469
+ }
470
+
419
471
  function ensureColumn(db, table, column, definition) {
420
472
  const existing = db.prepare(`PRAGMA table_info(${table})`).all();
421
473
  if (existing.some((row) => row.name === column)) return;
@@ -59,6 +59,7 @@ test('runtime store persists run identity fields on events', () => {
59
59
  origin: 'agent',
60
60
  runId: 'run-2',
61
61
  turnId: 'run-2:turn-1',
62
+ taskId: 'task-1',
62
63
  workspace: 'docs',
63
64
  payload: { content: 'done' },
64
65
  }));
@@ -66,10 +67,51 @@ test('runtime store persists run identity fields on events', () => {
66
67
  const [event] = store.listEvents();
67
68
  assert.equal(event.runId, 'run-2');
68
69
  assert.equal(event.turnId, 'run-2:turn-1');
70
+ assert.equal(event.taskId, 'task-1');
69
71
  assert.equal(event.workspace, 'docs');
70
72
  store.close();
71
73
  });
72
74
 
75
+ test('runtime store exposes a correlated audit trail', () => {
76
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
77
+ const store = openRuntimeStore({ stateDir });
78
+ store.persistEvent(createAgentEvent('tool_call_started', {
79
+ origin: 'agent',
80
+ runId: 'run-audit',
81
+ turnId: 'run-audit:turn-1',
82
+ taskId: 'task-a',
83
+ workspace: 'docs',
84
+ payload: { callId: 'call-1', name: 'production_start_job' },
85
+ }));
86
+ store.persistEvent(createAgentEvent('activity_upserted', {
87
+ origin: 'tool',
88
+ runId: 'run-audit',
89
+ turnId: 'run-audit:turn-1',
90
+ taskId: 'task-a',
91
+ workspace: 'docs',
92
+ payload: {
93
+ activity: {
94
+ id: 'job-1',
95
+ source: 'production',
96
+ kind: 'job',
97
+ label: 'Production job',
98
+ status: 'running',
99
+ progress: {},
100
+ poll: null,
101
+ outputRefs: [],
102
+ },
103
+ },
104
+ }));
105
+
106
+ const audit = store.listAuditTrail({ workspace: 'docs', runId: 'run-audit' });
107
+ assert.equal(audit.length, 2);
108
+ assert.equal(audit[0].toolCallId, 'call-1');
109
+ assert.equal(audit[0].taskId, 'task-a');
110
+ assert.equal(audit[1].activityId, 'job-1');
111
+ assert.equal(audit[1].workspace, 'docs');
112
+ store.close();
113
+ });
114
+
73
115
  test('runtime store replays evaluator verdict into state', () => {
74
116
  const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
75
117
  const store = openRuntimeStore({ stateDir });
@@ -404,12 +446,41 @@ test('runtime state exposes queue as blocked jobs and pending plan steps', () =>
404
446
  { id: 'q-done', workspace: 'docs', status: 'done', args: { type: 'done' } },
405
447
  ], { workspace: 'docs' });
406
448
 
407
- const queue = store.getState(session, { workspace: 'docs' }).queue;
449
+ const state = store.getState(session, { workspace: 'docs' });
450
+ const queue = state.queue;
408
451
  assert.deepEqual(queue.map((item) => item.id), ['q-wait', 'plan-2', 'plan-3']);
409
452
  assert.equal(queue[0].queueType, 'blocked_job');
410
453
  assert.equal(queue[1].queueType, 'pending_step');
411
454
  assert.equal(queue[1].args.type, 'Build');
455
+ assert.ok(state.workflow.nodes.some((node) => node.id === 'task:2'));
456
+ assert.ok(state.workflow.nodes.some((node) => node.id === 'queue:q-wait'));
457
+ assert.ok(state.workflow.waitingReasons.includes('queue:q-wait'));
458
+ store.close();
459
+ });
460
+
461
+ test('runtime store persists and replays control queue events without mixing MCP queue', () => {
462
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
463
+ const store = openRuntimeStore({ stateDir });
464
+ store.persistEvent(createAgentEvent('control_enqueued', {
465
+ origin: 'runtime',
466
+ workspace: 'docs',
467
+ payload: { id: 'control-docs', workspace: 'docs', input: 'later' },
468
+ }));
469
+ store.persistEvent(createAgentEvent('control_enqueued', {
470
+ origin: 'runtime',
471
+ workspace: 'juno',
472
+ payload: { id: 'control-juno', workspace: 'juno', input: 'other' },
473
+ }));
412
474
  store.close();
475
+
476
+ const reopened = openRuntimeStore({ stateDir });
477
+ const session = { activities: {}, headlessPlan: null };
478
+ reopened.hydrateSession(session, { workspace: 'docs' });
479
+ const state = reopened.getState(session, { workspace: 'docs' });
480
+ assert.deepEqual(state.controlQueue.map((item) => item.id), ['control-docs']);
481
+ assert.deepEqual(state.queue, []);
482
+ assert.deepEqual(session.controlQueue.map((item) => item.id), ['control-docs']);
483
+ reopened.close();
413
484
  });
414
485
 
415
486
  test('runtime store identifies recoverable workspaces and interrupts stale runs', () => {
@@ -1,5 +1,6 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import { createMemo, Index, Show } from 'solid-js';
3
+ import { fit } from './textFit';
3
4
 
4
5
  type PlanStep = { step: number; description: string; status: string };
5
6
  type QueueItem = {
@@ -45,13 +46,6 @@ function wrapLine(value: string, width: number) {
45
46
  return lines;
46
47
  }
47
48
 
48
- function fit(value: string, width: number) {
49
- const max = Math.max(1, width);
50
- if (value.length <= max) return value;
51
- if (max <= 1) return '…';
52
- return value.slice(0, max - 1) + '…';
53
- }
54
-
55
49
  function logLineParts(line: string): LogLineParts {
56
50
  const match = line.match(/^((?:\d{1,2}:\d{2}(?::\d{2})?(?:\s?[AP]M)?|\d{4}-\d{2}-\d{2}[T ][0-9:.]+Z?))\s+(.+)$/i);
57
51
  if (!match) return { time: null, message: line };
@@ -0,0 +1,212 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { useKeyboard } from '@opentui/solid';
3
+ import { createMemo, createSignal, For } from 'solid-js';
4
+ import { fit } from './textFit';
5
+
6
+ export type StartupAction =
7
+ | 'new-conversation'
8
+ | 'open-workspace'
9
+ | 'run-workflow'
10
+ | 'init-workspace';
11
+
12
+ type StartupItem = {
13
+ action: StartupAction;
14
+ label: string;
15
+ detail: string;
16
+ };
17
+
18
+ function statusDot(ready: boolean) {
19
+ return ready ? '●' : '○';
20
+ }
21
+
22
+ function initWorkspaceItem(detail: string): StartupItem {
23
+ return { action: 'init-workspace', label: 'Initialize workspace', detail };
24
+ }
25
+
26
+ export function StartupScreen(props: {
27
+ version: string;
28
+ model: string;
29
+ connectedMcpServers: number;
30
+ wikiReady: boolean;
31
+ workspaceName?: string | null;
32
+ profileName?: string | null;
33
+ workspaces: string[];
34
+ hasWorkspace: boolean;
35
+ width: number;
36
+ height: number;
37
+ onSelect: (action: StartupAction, workspaceName?: string) => void;
38
+ onQuit: () => void;
39
+ }) {
40
+ const [mode, setMode] = createSignal<'home' | 'workspace-select'>('home');
41
+ const [selected, setSelected] = createSignal(0);
42
+ const panelWidth = createMemo(() => Math.max(54, Math.min(86, props.width - 4)));
43
+ const panelHeight = createMemo(() => Math.max(24, Math.min(32, props.height - 2)));
44
+ const left = createMemo(() => Math.max(1, Math.floor((props.width - panelWidth()) / 2)));
45
+ const top = createMemo(() => Math.max(1, Math.floor((props.height - panelHeight()) / 2)));
46
+
47
+ const items = createMemo<StartupItem[]>(() => {
48
+ if (!props.hasWorkspace) {
49
+ return [initWorkspaceItem('Create and configure the default workspace')];
50
+ }
51
+ return [
52
+ {
53
+ action: 'new-conversation',
54
+ label: 'Start a new conversation',
55
+ detail: props.workspaceName ? `Load ${props.workspaceName} with default .wikirc` : 'Load default .wikirc',
56
+ },
57
+ {
58
+ action: 'open-workspace',
59
+ label: 'Open workspace',
60
+ detail: 'Select, load, then start agents and services',
61
+ },
62
+ initWorkspaceItem('Create and configure a new workspace'),
63
+ {
64
+ action: 'run-workflow',
65
+ label: 'Run a workflow',
66
+ detail: 'Switch to Agent mode after loading the workspace',
67
+ },
68
+ ];
69
+ });
70
+ const workspaceItems = createMemo(() => props.workspaces.map((name) => ({
71
+ label: name,
72
+ detail: name === props.workspaceName ? 'default workspace' : 'available workspace',
73
+ })));
74
+ const currentLength = createMemo(() => mode() === 'workspace-select' ? workspaceItems().length : items().length);
75
+
76
+ const move = (delta: number) => {
77
+ setSelected((value) => (value + delta + currentLength()) % currentLength());
78
+ };
79
+
80
+ const choose = (index = selected()) => {
81
+ if (mode() === 'workspace-select') {
82
+ const item = workspaceItems()[index];
83
+ if (item) props.onSelect('open-workspace', item.label);
84
+ return;
85
+ }
86
+ const item = items()[index];
87
+ if (!item) return;
88
+ if (item.action === 'open-workspace') {
89
+ setMode('workspace-select');
90
+ setSelected(0);
91
+ return;
92
+ }
93
+ props.onSelect(item.action);
94
+ };
95
+
96
+ useKeyboard((key: any) => {
97
+ const keyName = String(key.name ?? '').toLowerCase();
98
+ const sequence = String(key.sequence ?? '');
99
+ if ((key.ctrl || key.meta) && keyName === 'c') {
100
+ props.onQuit();
101
+ return;
102
+ }
103
+ if (keyName === 'escape' && mode() === 'workspace-select') {
104
+ setMode('home');
105
+ setSelected(0);
106
+ return;
107
+ }
108
+ if (keyName === 'escape') {
109
+ props.onQuit();
110
+ return;
111
+ }
112
+ if (keyName === 'up') {
113
+ move(-1);
114
+ return;
115
+ }
116
+ if (keyName === 'down') {
117
+ move(1);
118
+ return;
119
+ }
120
+ if (keyName === 'return' || keyName === 'enter') {
121
+ choose();
122
+ return;
123
+ }
124
+ const numeric = Number.parseInt(sequence, 10);
125
+ if (Number.isInteger(numeric) && numeric >= 1 && numeric <= currentLength()) {
126
+ choose(numeric - 1);
127
+ }
128
+ });
129
+
130
+ const status = createMemo(() => {
131
+ if (!props.hasWorkspace) return 'Setup needed';
132
+ return props.wikiReady ? 'Ready' : 'Default .wikirc missing';
133
+ });
134
+
135
+ const subtitle = createMemo(() => {
136
+ const workspace = props.workspaceName ?? 'no workspace';
137
+ const profile = props.profileName ?? 'default';
138
+ return `${workspace} / ${profile}`;
139
+ });
140
+
141
+ const innerWidth = createMemo(() => Math.max(40, panelWidth() - 6));
142
+ const shortcutHint = createMemo(() => {
143
+ const count = currentLength();
144
+ const quick = count === 1 ? '1 quick select' : `1-${count} quick select`;
145
+ return mode() === 'workspace-select'
146
+ ? `↑/↓ select Enter open ${quick} Esc back`
147
+ : `↑/↓ select Enter open ${quick} Esc quit`;
148
+ });
149
+ const menuTitle = createMemo(() => mode() === 'workspace-select' ? 'Select workspace' : '');
150
+
151
+ return (
152
+ <box width="100%" height="100%" backgroundColor="#0B0D12">
153
+ <box
154
+ position="absolute"
155
+ left={left()}
156
+ top={top()}
157
+ width={panelWidth()}
158
+ height={panelHeight()}
159
+ border
160
+ borderStyle="rounded"
161
+ borderColor="#8BD5CA"
162
+ backgroundColor="#111318"
163
+ padding={2}
164
+ flexDirection="column"
165
+ overflow="hidden"
166
+ >
167
+ <box height={1} flexDirection="row">
168
+ <text fg="#8BD5CA" content={fit(`DONNA v${props.version}`, Math.floor(innerWidth() * 0.5))} />
169
+ <text fg={props.wikiReady ? '#8BD5CA' : '#FBBF24'} content={fit(` ${statusDot(props.wikiReady)} ${status()}`, Math.floor(innerWidth() * 0.45))} />
170
+ </box>
171
+ <text height={1} fg="#7F8C8D" content={fit(subtitle(), innerWidth())} />
172
+ <text height={1}>{''}</text>
173
+ <text height={1} fg="#D6DEE8">██████╗ ██████╗ ███╗ ██╗███╗ ██╗ █████╗</text>
174
+ <text height={1} fg="#D6DEE8">██╔══██╗██╔═══██╗████╗ ██║████╗ ██║██╔══██╗</text>
175
+ <text height={1} fg="#D6DEE8">██║ ██║██║ ██║██╔██╗ ██║██╔██╗ ██║███████║</text>
176
+ <text height={1} fg="#D6DEE8">██║ ██║██║ ██║██║╚██╗██║██║╚██╗██║██╔══██║</text>
177
+ <text height={1} fg="#D6DEE8">██████╔╝╚██████╔╝██║ ╚████║██║ ╚████║██║ ██║</text>
178
+ <text height={1} fg="#D6DEE8">╚═════╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═══╝╚═╝ ╚═╝</text>
179
+ <text height={1}>{''}</text>
180
+ <text height={1} fg="#7F8C8D" content={menuTitle()} />
181
+ <box height={Math.max(1, currentLength())} flexDirection="column">
182
+ <For each={mode() === 'workspace-select' ? workspaceItems() : items()}>
183
+ {(item, index) => {
184
+ const active = () => index() === selected();
185
+ return (
186
+ <box
187
+ height={1}
188
+ flexDirection="row"
189
+ onMouseUp={() => choose(index())}
190
+ >
191
+ <text width={4} fg={active() ? '#111318' : '#7F8C8D'} bg={active() ? '#8BD5CA' : undefined}>
192
+ {active() ? '›' : ' '} {index() + 1}
193
+ </text>
194
+ <text width={28} fg={active() ? '#8BD5CA' : '#D6DEE8'} content={fit(item.label, 27)} />
195
+ <text fg="#7F8C8D" content={fit(item.detail, Math.max(8, innerWidth() - 34))} />
196
+ </box>
197
+ );
198
+ }}
199
+ </For>
200
+ </box>
201
+ <text height={1}>{''}</text>
202
+ <box height={3} flexDirection="column" border={['left']} borderStyle="heavy" borderColor="#5DADE2" paddingX={1}>
203
+ <text height={1} fg="#D6DEE8" content={fit(`LLM ${statusDot(Boolean(props.model))} ${props.model || 'not configured'}`, innerWidth() - 2)} />
204
+ <text height={1} fg="#D6DEE8" content={fit(`MCP ${statusDot(props.connectedMcpServers > 0)} ${props.connectedMcpServers} server(s) configured`, innerWidth() - 2)} />
205
+ <text height={1} fg="#D6DEE8" content={fit(`Wiki ${statusDot(props.wikiReady)} ${props.wikiReady ? 'default profile ready' : 'init required'}`, innerWidth() - 2)} />
206
+ </box>
207
+ <text height={1}>{''}</text>
208
+ <text height={1} fg="#7F8C8D" content={shortcutHint()} />
209
+ </box>
210
+ </box>
211
+ );
212
+ }
package/src/shell/repl.js CHANGED
@@ -15,7 +15,7 @@ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
15
15
  import { listSkills } from '../core/skills.js';
16
16
  import { listWikircProfiles } from '../core/wikirc.js';
17
17
  import { listWorkspaces } from '../core/workspaces.js';
18
- import { fetchRuntimeState, postRuntimeApprove, postRuntimeCancel, postRuntimeRun, streamRuntimeEvents } from '../runtime/client.js';
18
+ import { fetchRuntimeState, postRuntimeApprove, postRuntimeCancel, postRuntimeControl, postRuntimeRun, streamRuntimeEvents } from '../runtime/client.js';
19
19
 
20
20
  marked.use(markedTerminal());
21
21
  // marked-terminal's text renderer extracts token.text (raw string) instead of
@@ -743,7 +743,18 @@ export function applyRuntimeStateToShellSession(session, state) {
743
743
  logs: Array.isArray(state.logs) ? [...state.logs] : [],
744
744
  summary: state.summary ?? null,
745
745
  status: state.status ?? 'idle',
746
+ planRevision: state.planRevision ?? 0,
747
+ planPatches: Array.isArray(state.planPatches) ? state.planPatches.map((patch) => ({ ...patch })) : [],
746
748
  };
749
+ session.workflow = state.workflow && typeof state.workflow === 'object'
750
+ ? {
751
+ ...state.workflow,
752
+ nodes: Array.isArray(state.workflow.nodes) ? state.workflow.nodes.map((node) => ({ ...node })) : [],
753
+ relations: Array.isArray(state.workflow.relations) ? state.workflow.relations.map((relation) => ({ ...relation })) : [],
754
+ waitingReasons: Array.isArray(state.workflow.waitingReasons) ? [...state.workflow.waitingReasons] : [],
755
+ warnings: Array.isArray(state.workflow.warnings) ? [...state.workflow.warnings] : [],
756
+ }
757
+ : null;
747
758
  session.headlessPlan = session.agentProjection.plan
748
759
  ? session.agentProjection.plan.map((step) => ({ ...step }))
749
760
  : null;
@@ -772,6 +783,27 @@ export function applyRuntimeStateToShellSession(session, state) {
772
783
  return true;
773
784
  }
774
785
 
786
+ // Submits a prompt to the shared runtime. If the workspace is already busy
787
+ // (HTTP 409 from POST /run), route the input through the runtime control lane
788
+ // so status questions and plan-change proposals do not become future runs.
789
+ export async function submitRuntimeRun(line, { runtime, session }) {
790
+ const workspace = session.workspace ?? null;
791
+ try {
792
+ await postRuntimeRun(line, { url: runtime.url, workspace });
793
+ return { kind: 'accepted' };
794
+ } catch (err) {
795
+ if (err?.status !== 409) {
796
+ return { kind: 'error', message: err instanceof Error ? err.message : String(err) };
797
+ }
798
+ try {
799
+ const result = await postRuntimeControl('message', { url: runtime.url, workspace, input: line });
800
+ return { kind: result?.kind ?? 'control', result };
801
+ } catch (queueErr) {
802
+ return { kind: 'error', message: queueErr instanceof Error ? queueErr.message : String(queueErr) };
803
+ }
804
+ }
805
+ }
806
+
775
807
  function activityText(session) {
776
808
  const activity = sessionActivities(session).find((item) => !item.terminal)
777
809
  ?? (session.productionActivity?.label ? session.productionActivity : null);
@@ -1499,13 +1531,25 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
1499
1531
  syncRuntimeState();
1500
1532
  }
1501
1533
  } else if (runtime?.url && !session.chatMode && !line.trim().startsWith('/')) {
1502
- await postRuntimeRun(line, {
1503
- url: runtime.url,
1504
- workspace: session.workspace ?? null,
1505
- });
1506
1534
  conversationMessages(session).push({ role: 'user', content: line });
1507
- conversationMessages(session).push({ role: 'command', content: `Runtime run queued: ${runtime.url}` });
1508
- activityLines = [...activityLines, 'runtime: run accepted'].slice(-LOWER_DETAIL_ROWS);
1535
+ const outcome = await submitRuntimeRun(line, { runtime, session });
1536
+ if (outcome.kind === 'accepted') {
1537
+ conversationMessages(session).push({ role: 'command', content: `Runtime run queued: ${runtime.url}` });
1538
+ activityLines = [...activityLines, 'runtime: run accepted'].slice(-LOWER_DETAIL_ROWS);
1539
+ } else if (outcome.kind === 'queued') {
1540
+ conversationMessages(session).push({ role: 'command', content: 'Runtime is busy — request added to the control queue, it will start automatically.' });
1541
+ activityLines = [...activityLines, 'runtime: control queued'].slice(-LOWER_DETAIL_ROWS);
1542
+ } else if (outcome.kind === 'observe' || outcome.kind === 'converse' || outcome.kind === 'mutate') {
1543
+ const explanation = outcome.result?.explanation ?? 'Runtime control message accepted.';
1544
+ conversationMessages(session).push({ role: 'command', content: explanation });
1545
+ activityLines = [...activityLines, `runtime: ${outcome.kind}`].slice(-LOWER_DETAIL_ROWS);
1546
+ } else if (outcome.kind === 'ambiguous') {
1547
+ conversationMessages(session).push({ role: 'command', content: 'Runtime could not classify that message. Use /queue for a future run, or ask/status more explicitly.' });
1548
+ activityLines = [...activityLines, 'runtime: ambiguous control'].slice(-LOWER_DETAIL_ROWS);
1549
+ } else {
1550
+ conversationMessages(session).push({ role: 'command', content: `Runtime error: ${outcome.message}` });
1551
+ activityLines = [...activityLines, `runtime error: ${outcome.message}`].slice(-LOWER_DETAIL_ROWS);
1552
+ }
1509
1553
  syncRuntimeState();
1510
1554
  } else {
1511
1555
  const result = await runLine(line, { agent, packageJson, session, onUpdate: rerender, onStep });
@@ -1,6 +1,20 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { applyRuntimeStateToShellSession, createSession, conversationMessages } from './repl.js';
3
+ import { applyRuntimeStateToShellSession, createSession, conversationMessages, submitRuntimeRun } from './repl.js';
4
+
5
+ function stubFetch(handler) {
6
+ const original = globalThis.fetch;
7
+ globalThis.fetch = handler;
8
+ return () => { globalThis.fetch = original; };
9
+ }
10
+
11
+ function jsonResponse(status, body) {
12
+ return { ok: status >= 200 && status < 300, status, text: async () => JSON.stringify(body), json: async () => body };
13
+ }
14
+
15
+ function pathOf(url) {
16
+ return new URL(String(url)).pathname;
17
+ }
4
18
 
5
19
  test('applyRuntimeStateToShellSession projects runtime state into shell session', () => {
6
20
  const session = createSession();
@@ -22,6 +36,12 @@ test('applyRuntimeStateToShellSession projects runtime state into shell session'
22
36
  terminal: false,
23
37
  }],
24
38
  queue: [{ id: 'q-1', status: 'waiting' }],
39
+ workflow: {
40
+ nodes: [{ id: 'task:build', type: 'task', label: 'Build', status: 'running' }],
41
+ relations: [{ type: 'contains', from: 'run:run-1', to: 'task:build' }],
42
+ waitingReasons: ['queue:q-1'],
43
+ warnings: ['legacy_sequential_plan'],
44
+ },
25
45
  logs: ['agentic-loop: turn 1/20'],
26
46
  });
27
47
 
@@ -31,8 +51,64 @@ test('applyRuntimeStateToShellSession projects runtime state into shell session'
31
51
  assert.equal(session.activities['production:job-1'].status, 'running');
32
52
  assert.equal(session.productionActivity.jobId, 'job-1');
33
53
  assert.equal(session.jobQueue[0].id, 'q-1');
54
+ assert.equal(session.workflow.nodes[0].id, 'task:build');
55
+ assert.equal(session.workflow.relations[0].type, 'contains');
56
+ assert.deepEqual(session.workflow.waitingReasons, ['queue:q-1']);
34
57
  assert.deepEqual(conversationMessages(session), [
35
58
  { role: 'user', content: 'Build docs' },
36
59
  { role: 'donna', content: 'Working.' },
37
60
  ]);
38
61
  });
62
+
63
+ test('submitRuntimeRun reports acceptance without throwing', async () => {
64
+ const restore = stubFetch(async (url) => {
65
+ assert.equal(pathOf(url), '/run');
66
+ return jsonResponse(202, { accepted: true, runId: 'run-1' });
67
+ });
68
+ try {
69
+ const session = createSession();
70
+ const outcome = await submitRuntimeRun('build the doc', { runtime: { url: 'http://runtime.test' }, session });
71
+ assert.deepEqual(outcome, { kind: 'accepted' });
72
+ } finally {
73
+ restore();
74
+ }
75
+ });
76
+
77
+ test('submitRuntimeRun routes busy runtime input through the control lane', async () => {
78
+ let controlBody = null;
79
+ const restore = stubFetch(async (url, init) => {
80
+ const path = pathOf(url);
81
+ if (path === '/run') return jsonResponse(409, { error: 'A runtime run is already active.' });
82
+ if (path === '/control') {
83
+ controlBody = JSON.parse(String(init.body));
84
+ return jsonResponse(200, { accepted: true, kind: 'observe', explanation: 'Runtime run is active.' });
85
+ }
86
+ throw new Error(`unexpected fetch: ${url}`);
87
+ });
88
+ try {
89
+ const session = createSession();
90
+ const outcome = await submitRuntimeRun('Où en est le build ?', { runtime: { url: 'http://runtime.test' }, session });
91
+ assert.equal(outcome.kind, 'observe');
92
+ assert.equal(outcome.result.explanation, 'Runtime run is active.');
93
+ assert.deepEqual(controlBody, { action: 'message', input: 'Où en est le build ?' });
94
+ } finally {
95
+ restore();
96
+ }
97
+ });
98
+
99
+ test('submitRuntimeRun reports a non-409 error without throwing or calling /control', async () => {
100
+ let controlCalled = false;
101
+ const restore = stubFetch(async (url) => {
102
+ if (pathOf(url) === '/control') controlCalled = true;
103
+ return jsonResponse(503, { error: 'runtime unavailable' });
104
+ });
105
+ try {
106
+ const session = createSession();
107
+ const outcome = await submitRuntimeRun('build the doc', { runtime: { url: 'http://runtime.test' }, session });
108
+ assert.equal(outcome.kind, 'error');
109
+ assert.match(outcome.message, /503/);
110
+ assert.equal(controlCalled, false);
111
+ } finally {
112
+ restore();
113
+ }
114
+ });
@@ -0,0 +1,6 @@
1
+ export function fit(value: string, width: number): string {
2
+ const max = Math.max(1, width);
3
+ if (value.length <= max) return value;
4
+ if (max <= 1) return '…';
5
+ return value.slice(0, max - 1) + '…';
6
+ }