@dotdrelle/wiki-manager 0.14.16 → 0.14.20

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/.env.example CHANGED
@@ -74,10 +74,24 @@ DOCUMENTS_MCP_AUTH_TOKEN=
74
74
  # WIKI_MANAGER_RUNTIME_HOST=0.0.0.0
75
75
 
76
76
 
77
- # Parallel tasks dispatched at once for capability runs. Defaults to what the
78
- # agent itself declares (agent_describe limits); set only to constrain it.
79
- # Example constraint (never raises an agent's declared capacity):
80
- # WIKI_MANAGER_CAPABILITY_CONCURRENCY=3
77
+ # ── Parallelism & throughput ───────────────────────────────────────────────────
78
+ # Effective concurrency = MIN(agent recommendedConcurrency, agent maxConcurrency,
79
+ # this ceiling, per-task limits). So the PRIMARY levers live on the production
80
+ # agent (PRODUCTION_RECOMMENDED_CONCURRENCY / PRODUCTION_MAX_CONCURRENCY); this
81
+ # manager variable can only LOWER the result, never raise it. Full explanation
82
+ # and low/high profiles in docs/configuration.md § "Parallelism & throughput".
83
+ #
84
+ # Manager ceiling — leave unset to let the agent decide. Set to constrain:
85
+ # WIKI_MANAGER_CAPABILITY_CONCURRENCY=4
86
+ #
87
+ # Production agent capacity (passed through by docker-compose). Intermediate
88
+ # defaults are 4/8 (effective ≈ 4 parallel). Profiles:
89
+ # low → PRODUCTION_RECOMMENDED_CONCURRENCY=2 PRODUCTION_MAX_CONCURRENCY=4
90
+ # high → PRODUCTION_RECOMMENDED_CONCURRENCY=8 PRODUCTION_MAX_CONCURRENCY=16
91
+ # The wiki LLM backend must accept this many concurrent requests, and
92
+ # ingest_apply stays serialized regardless (global workspace-write lock).
93
+ # PRODUCTION_RECOMMENDED_CONCURRENCY=4
94
+ # PRODUCTION_MAX_CONCURRENCY=8
81
95
 
82
96
  # ── MCP retry policy (optional) ────────────────────────────────────────────────
83
97
 
package/README.md CHANGED
@@ -550,6 +550,25 @@ or the shell command `/approve item <id>`. The approval timeout defaults to 10
550
550
  minutes and can be changed with `WIKI_MANAGER_APPROVAL_TIMEOUT_MS` or
551
551
  `approvalTimeoutMs` in the `/run` body.
552
552
 
553
+ Directly-launched capability runs (ingest, pipeline) now **wait for approval by
554
+ default** before their mutating tasks: reply "valide tout", run `/approve`, or
555
+ click Approve in either UI (Shell right-pane banner, or the `serve` banner above
556
+ the composer). Auto-approval only happens when the run is started with
557
+ `autoApprove: true` (headless/CI).
558
+
559
+ ### Parallelism & throughput
560
+
561
+ The number of tasks that run at once is `MIN(agent recommendedConcurrency, agent
562
+ maxConcurrency, WIKI_MANAGER_CAPABILITY_CONCURRENCY, per-task limits)` — a
563
+ minimum, so the manager ceiling can only lower it. The production agent ships
564
+ intermediate defaults (`PRODUCTION_RECOMMENDED_CONCURRENCY=4` /
565
+ `PRODUCTION_MAX_CONCURRENCY=8`, ≈ 4 parallel); locks then cap real parallelism
566
+ per phase (`ingest_apply` stays serial). The resolved value is shown in both
567
+ UIs' run summary and on the run node of the execution graph, with an amber
568
+ "(ceiling)" marker when the manager ceiling binds. Low/high profiles, the lock
569
+ model and the LLM-backend caveat are in
570
+ [docs/configuration.md § "Parallelism & throughput"](docs/configuration.md).
571
+
553
572
  While a run is active, `GET`/`POST /control` still answers without waiting for
554
573
  it to finish: `{"action":"status"}` returns the current run/plan/queue state,
555
574
  `{"action":"explain"}` adds a one-line plain-language summary, and
@@ -110,6 +110,11 @@ services:
110
110
  - WIKI_CONFIG_PATH=${WIKI_CONFIG_PATH:-}
111
111
  - PRODUCTION_ALLOWED_STEPS=${PRODUCTION_ALLOWED_STEPS:-doctor,ingest,ingest_plan,ingest_apply,build,export,polish,pipeline}
112
112
  - PRODUCTION_REQUIRE_CONFIRMATION=${PRODUCTION_REQUIRE_CONFIRMATION:-false}
113
+ # Parallelism levers — effective concurrency ≈ recommendedConcurrency.
114
+ # Intermediate defaults (4/8). Low profile 2/4, high profile 8/16.
115
+ # See docs/configuration.md § "Parallelism & throughput".
116
+ - PRODUCTION_RECOMMENDED_CONCURRENCY=${PRODUCTION_RECOMMENDED_CONCURRENCY:-4}
117
+ - PRODUCTION_MAX_CONCURRENCY=${PRODUCTION_MAX_CONCURRENCY:-8}
113
118
  - PRODUCTION_JOBS_DIR=${PRODUCTION_JOBS_DIR:-/workspace/.wiki/production-jobs}
114
119
  - PRODUCTION_LOCKS_DIR=${PRODUCTION_LOCKS_DIR:-/workspace/.wiki/production-jobs/locks}
115
120
  ports:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.14.16",
3
+ "version": "0.14.20",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "author": "dotrelle",
@@ -93,17 +93,21 @@ function groupLine(group, activities) {
93
93
  // still progressing. Show the live worker as running; surface the group
94
94
  // failure once no work remains. Otherwise its business label was rendered
95
95
  // red even though that exact task was healthy and advancing.
96
+ // Glyphs mirror the Shell PlanPanel so the two never disagree: done is a
97
+ // check (not a cross — "[x]" read as a failure X), failure is a distinct
98
+ // cross, and an approval wait gets its own pause glyph instead of reusing the
99
+ // failure "[!]".
96
100
  if (running.length > 0 || activeActivity) {
97
101
  icon = '[...]';
98
102
  status = activeProgress != null ? `${Math.round(activeProgress)} %` : `${done}/${total}`;
99
103
  } else if (failed) {
100
- icon = '[!]';
104
+ icon = '[✗]';
101
105
  status = 'error';
102
106
  } else if (done === total) {
103
- icon = '[x]';
107
+ icon = '[✓]';
104
108
  status = 'done';
105
109
  } else if (waitingApproval) {
106
- icon = '[!]';
110
+ icon = '[⏸]';
107
111
  status = 'validation';
108
112
  } else if (done > 0) {
109
113
  icon = '[...]';
@@ -5,6 +5,20 @@ import { aggregateActivity } from './activityAggregator.js';
5
5
  import { visibleActivityEvents } from './activityDeduplicator.js';
6
6
  import { calculateWeightedProgress } from './progressCalculator.js';
7
7
 
8
+ test('a group awaiting approval renders a distinct pause glyph, not a failure', () => {
9
+ const aggregated = aggregateActivity({
10
+ plan: [
11
+ { id: 'publish', label: 'Publication', groupId: 'publish', status: 'pending_approval', progressWeight: 1 },
12
+ ],
13
+ activities: [],
14
+ });
15
+ const line = aggregated.lines.find((item) => /publish|Publication/.test(item.label));
16
+ assert.ok(line, 'the awaiting-approval group is present');
17
+ assert.match(line.label, /\[⏸\]/, 'uses the pause glyph');
18
+ assert.doesNotMatch(line.label, /\[!\]|\[✗\]/, 'never reuses the failure glyph');
19
+ assert.equal(line.status, 'validation');
20
+ });
21
+
8
22
  test('activityDeduplicator keeps one visible entry for repeated 2 percent polls', () => {
9
23
  const events = Array.from({ length: 50 }, () => ({
10
24
  type: 'activity_upserted',
@@ -54,7 +68,7 @@ test('aggregateActivity exposes initial synthesis and grouped display lines', ()
54
68
 
55
69
  assert.deepEqual(activity.initialSynthesis, ['120 sources detectees', '6 traitements simultanes recommandes']);
56
70
  assert.equal(activity.progress.percent, 54);
57
- assert.ok(activity.lines.some((line) => /\[x\] collect - done/.test(line.label)));
71
+ assert.ok(activity.lines.some((line) => /\[✓\] collect - done/.test(line.label)));
58
72
  assert.ok(activity.lines.some((line) => /\[\.\.\.\] customer-data\.enrich - 63 %/.test(line.label)));
59
73
  const enrichLine = activity.lines.find((line) => /customer-data\.enrich/.test(line.label));
60
74
  assert.equal(enrichLine.progress.label, 'Export rapport.md');
@@ -92,7 +106,7 @@ test('aggregateActivity keeps activities not attached to any plan task visible',
92
106
 
93
107
  const aggregated = aggregateActivity(state, []);
94
108
  const labels = aggregated.lines.map((line) => line.label).join('\n');
95
- assert.match(labels, /\[x\] .* done/, 'the done plan group stays visible');
109
+ assert.match(labels, /\[✓\] .* done/, 'the done plan group stays visible');
96
110
  assert.match(labels, /Ingest b87acaf6/, 'the unattached running ingest must appear');
97
111
  const ingestLine = aggregated.lines.find((line) => /Ingest/.test(line.label));
98
112
  assert.equal(ingestLine.status, 'running');
@@ -114,6 +114,18 @@ export async function forwardRuntimeApproval(getWorkspaceContext, request = {})
114
114
  return context.approvalManager?.approve(request) ?? { approved: false };
115
115
  }
116
116
 
117
+ export function resolvePreparedDelegationApproval({
118
+ autoApprove = false,
119
+ approvalManager = null,
120
+ runId,
121
+ } = {}) {
122
+ if (autoApprove !== true || typeof approvalManager?.approve !== 'function') {
123
+ return { approved: false, awaitingApproval: true };
124
+ }
125
+ const result = approvalManager.approve({ scope: 'run', runId });
126
+ return { approved: true, awaitingApproval: false, result };
127
+ }
128
+
117
129
  function timestampForFile() {
118
130
  return new Date().toISOString().replace(/[:.]/g, '-');
119
131
  }
@@ -923,14 +935,24 @@ async function runRuntime(argv, agent) {
923
935
  throw new Error(`Delegated plan integration failed: ${(integrated.errors ?? []).map((error) => error.message ?? error.code ?? String(error)).join('; ')}`);
924
936
  }
925
937
  emitRuntimeLog(session, `delegation: ${prepared.fragment.tasks.length} validated task(s) integrated from ${prepared.provider.serverName}.agent_plan (${prepared.capability}/${prepared.operation})`);
926
- // Demandé = consenti: a directly-delegated run carries the user's
927
- // explicit consent, so auto-approve its initial plan. Persisting a
928
- // run-scope grant (via the approval manager) makes the scheduler's
929
- // readyTasks approval check pass, so the tasks run without re-prompting.
930
- // Replanned tasks are integrated later without a fresh grant.
931
- if (context.approvalManager?.approve) {
932
- context.approvalManager.approve({ scope: 'run', runId });
933
- emitRuntimeLog(session, `approval: run ${runId} auto-approved (user-requested action)`);
938
+ // Real approval gate (opt-out): a directly-delegated run only skips the
939
+ // human approval step when the caller explicitly opts in via
940
+ // `autoApprove` (e.g. headless/CI, or a future "trust this run" toggle).
941
+ // By default the run WAITS: integrate() above created the per-task
942
+ // approval requests, and the scheduler's approvalCovered() filter blocks
943
+ // the mutating tasks until a run-scope grant arrives (/approve or
944
+ // "valide tout"). This keeps a visible pending_approval window instead of
945
+ // resolving it programmatically ~30ms after launch, which no polled UI
946
+ // could ever render.
947
+ const approval = resolvePreparedDelegationApproval({
948
+ autoApprove: body.autoApprove,
949
+ approvalManager: context.approvalManager,
950
+ runId,
951
+ });
952
+ if (approval.approved) {
953
+ emitRuntimeLog(session, `approval: run ${runId} auto-approved (autoApprove opt-in)`);
954
+ } else {
955
+ emitRuntimeLog(session, `approval: run ${runId} awaiting explicit approval before mutations (/approve or « valide tout »)`);
934
956
  }
935
957
  body._planReady?.resolve?.({ runId, planRevision: session.agentProjection?.planRevision ?? 0 });
936
958
  }
@@ -1,6 +1,9 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { forwardRuntimeApproval } from './wiki-manager.js';
3
+ import {
4
+ forwardRuntimeApproval,
5
+ resolvePreparedDelegationApproval,
6
+ } from './wiki-manager.js';
4
7
 
5
8
  test('runtime approval bridge preserves the complete run-scoped grant', async () => {
6
9
  let forwarded = null;
@@ -26,3 +29,39 @@ test('runtime approval bridge preserves the complete run-scoped grant', async ()
26
29
  assert.deepEqual(forwarded, request);
27
30
  assert.deepEqual(result, { approved: true });
28
31
  });
32
+
33
+ test('prepared delegation waits for explicit approval by default', () => {
34
+ let calls = 0;
35
+ const result = resolvePreparedDelegationApproval({
36
+ runId: 'run-gated',
37
+ approvalManager: {
38
+ approve() {
39
+ calls += 1;
40
+ },
41
+ },
42
+ });
43
+
44
+ assert.equal(calls, 0);
45
+ assert.deepEqual(result, { approved: false, awaitingApproval: true });
46
+ });
47
+
48
+ test('prepared delegation only approves when autoApprove is explicitly true', () => {
49
+ let forwarded = null;
50
+ const result = resolvePreparedDelegationApproval({
51
+ autoApprove: true,
52
+ runId: 'run-headless',
53
+ approvalManager: {
54
+ approve(request) {
55
+ forwarded = request;
56
+ return { approved: true };
57
+ },
58
+ },
59
+ });
60
+
61
+ assert.deepEqual(forwarded, { scope: 'run', runId: 'run-headless' });
62
+ assert.deepEqual(result, {
63
+ approved: true,
64
+ awaitingApproval: false,
65
+ result: { approved: true },
66
+ });
67
+ });
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.14.16",
3
- "commit": "a603298"
2
+ "version": "0.14.20",
3
+ "commit": "8833a7e"
4
4
  }
package/src/core/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
3
3
 
4
- const WIKI_MANAGER_VERSION = '0.14.16';
4
+ const WIKI_MANAGER_VERSION = '0.14.20';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -107,11 +107,83 @@ export function projectWorkflow(state = {}, events = []) {
107
107
  activity,
108
108
  waitingReasons,
109
109
  warnings,
110
+ usage: summarizeTokenUsage(events),
111
+ timingByTask: summarizeTaskTiming(events),
110
112
  };
111
113
  projected.graph = aggregateGraph(projected, events);
112
114
  return projected;
113
115
  }
114
116
 
117
+ // Per-task wall-clock timing, derived from the lifecycle events. Start = the
118
+ // first assigned/started event, finish = the last terminal event. Display-only
119
+ // (the inspector shows duration + orders tasks into a temporal flow); never used
120
+ // for scheduling.
121
+ function summarizeTaskTiming(events = []) {
122
+ const byTask = {};
123
+ const START_EVENTS = new Set(['task.assigned', 'task.started']);
124
+ const END_EVENTS = new Set(['task.completed', 'task.failed', 'task.result_returned']);
125
+ for (const event of events) {
126
+ if (!START_EVENTS.has(event.type) && !END_EVENTS.has(event.type)) continue;
127
+ const taskId = String(event.taskId ?? event.payload?.taskId ?? '').replace(/^task:/, '');
128
+ if (!taskId) continue;
129
+ const ts = Number(new Date(event.ts).getTime());
130
+ if (!Number.isFinite(ts)) continue;
131
+ const entry = byTask[taskId] ?? (byTask[taskId] = { startedAt: null, finishedAt: null, durationMs: null });
132
+ if (START_EVENTS.has(event.type)) {
133
+ entry.startedAt = entry.startedAt == null ? ts : Math.min(entry.startedAt, ts);
134
+ } else {
135
+ entry.finishedAt = entry.finishedAt == null ? ts : Math.max(entry.finishedAt, ts);
136
+ }
137
+ }
138
+ for (const entry of Object.values(byTask)) {
139
+ if (entry.startedAt != null && entry.finishedAt != null && entry.finishedAt >= entry.startedAt) {
140
+ entry.durationMs = entry.finishedAt - entry.startedAt;
141
+ }
142
+ }
143
+ return byTask;
144
+ }
145
+
146
+ function summarizeTokenUsage(events = []) {
147
+ let inputTokens = 0;
148
+ let outputTokens = 0;
149
+ let totalTokens = 0;
150
+ let inputKnown = false;
151
+ let outputKnown = false;
152
+ let totalKnown = false;
153
+ const byTask = {};
154
+ const seen = new Set();
155
+ for (const event of events) {
156
+ if (!['task.result_returned', 'task.completed', 'task.failed'].includes(event.type)) continue;
157
+ const result = event.payload?.result ?? {};
158
+ const metrics = result.metrics ?? event.payload?.metrics ?? {};
159
+ const attemptId = result.attemptId ?? event.payload?.attemptId ?? event.id;
160
+ if (attemptId && seen.has(attemptId)) continue;
161
+ if (attemptId) seen.add(attemptId);
162
+ const input = metricNumber(metrics.inputTokens ?? metrics.promptTokens ?? metrics.prompt_tokens);
163
+ const output = metricNumber(metrics.outputTokens ?? metrics.completionTokens ?? metrics.completion_tokens);
164
+ const total = metricNumber(metrics.totalTokens ?? metrics.total_tokens ?? metrics.tokens);
165
+ if (input != null) { inputTokens += input; inputKnown = true; }
166
+ if (output != null) { outputTokens += output; outputKnown = true; }
167
+ if (total != null) { totalTokens += total; totalKnown = true; }
168
+ else if (input != null || output != null) { totalTokens += (input ?? 0) + (output ?? 0); totalKnown = true; }
169
+ const taskId = String(event.taskId ?? event.payload?.taskId ?? '');
170
+ if (taskId && (input != null || output != null || total != null)) {
171
+ const current = byTask[taskId] ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0, inputKnown: false, outputKnown: false, totalKnown: false };
172
+ if (input != null) { current.inputTokens += input; current.inputKnown = true; }
173
+ if (output != null) { current.outputTokens += output; current.outputKnown = true; }
174
+ current.totalTokens += total ?? ((input ?? 0) + (output ?? 0));
175
+ current.totalKnown = true;
176
+ byTask[taskId] = current;
177
+ }
178
+ }
179
+ return { inputTokens, outputTokens, totalTokens, inputKnown, outputKnown, totalKnown, byTask };
180
+ }
181
+
182
+ function metricNumber(value) {
183
+ const number = Number(value);
184
+ return Number.isFinite(number) && number >= 0 ? number : null;
185
+ }
186
+
115
187
  function currentRun(state, events) {
116
188
  const runId = state.runId ?? state.runs?.find((run) => isActiveStatus(run.status))?.id ?? events.findLast?.((event) => event.runId)?.runId ?? null;
117
189
  if (!runId && !state.status) return null;
@@ -64,3 +64,60 @@ test('projectWorkflow reports approval and queue waiting reasons', () => {
64
64
  assert.ok(workflow.waitingReasons.includes('approval:approval-1'));
65
65
  assert.ok(workflow.waitingReasons.includes('queue:queued-1'));
66
66
  });
67
+
68
+ test('projectWorkflow aggregates input and output tokens once per attempt', () => {
69
+ const state = {
70
+ status: 'done',
71
+ runId: 'run-usage',
72
+ plan: [{ id: 'build', description: 'Build', status: 'done' }],
73
+ activities: [],
74
+ queue: [],
75
+ approvals: [],
76
+ };
77
+ const result = {
78
+ attemptId: 'attempt-1',
79
+ metrics: { inputTokens: 1200, outputTokens: 300, totalTokens: 1500 },
80
+ };
81
+ const workflow = projectWorkflow(state, [
82
+ { id: 'event-1', type: 'task.result_returned', runId: 'run-usage', taskId: 'build', payload: { result } },
83
+ { id: 'event-2', type: 'task.completed', runId: 'run-usage', taskId: 'build', payload: { result } },
84
+ ]);
85
+
86
+ assert.deepEqual(workflow.usage, {
87
+ inputTokens: 1200,
88
+ outputTokens: 300,
89
+ totalTokens: 1500,
90
+ inputKnown: true,
91
+ outputKnown: true,
92
+ totalKnown: true,
93
+ byTask: {
94
+ build: {
95
+ inputTokens: 1200,
96
+ outputTokens: 300,
97
+ totalTokens: 1500,
98
+ inputKnown: true,
99
+ outputKnown: true,
100
+ totalKnown: true,
101
+ },
102
+ },
103
+ });
104
+ });
105
+
106
+ test('projectWorkflow derives per-task timing (start, finish, duration) from lifecycle events', () => {
107
+ const state = {
108
+ status: 'done',
109
+ runId: 'run-timing',
110
+ plan: [{ id: 'ingest', description: 'Ingest', status: 'done' }],
111
+ activities: [],
112
+ queue: [],
113
+ approvals: [],
114
+ };
115
+ const workflow = projectWorkflow(state, [
116
+ { id: 'e1', type: 'task.started', runId: 'run-timing', taskId: 'ingest', ts: '2026-07-23T10:00:00.000Z', payload: {} },
117
+ { id: 'e2', type: 'task.completed', runId: 'run-timing', taskId: 'ingest', ts: '2026-07-23T10:00:12.500Z', payload: {} },
118
+ ]);
119
+
120
+ assert.equal(workflow.timingByTask.ingest.startedAt, Date.parse('2026-07-23T10:00:00.000Z'));
121
+ assert.equal(workflow.timingByTask.ingest.finishedAt, Date.parse('2026-07-23T10:00:12.500Z'));
122
+ assert.equal(workflow.timingByTask.ingest.durationMs, 12500);
123
+ });
@@ -9,7 +9,11 @@ export function resolveSchedulerConcurrency(value = process.env.WIKI_MANAGER_SCH
9
9
  : DEFAULT_SCHEDULER_CONCURRENCY;
10
10
  }
11
11
 
12
- export function resolvePlanConcurrency({ plan = [], agents = [], configured = null } = {}) {
12
+ // Display-only breakdown of the SAME computation resolvePlanConcurrency uses.
13
+ // resolvePlanConcurrency delegates to this so the number surfaced to the UIs can
14
+ // never diverge from the number the scheduler actually enforces. Never used to
15
+ // gate scheduling — only `.limit` feeds startReadyTasks.
16
+ export function describePlanConcurrency({ plan = [], agents = [], configured = null } = {}) {
13
17
  const capabilities = new Set(plan.map((task) => task?.requiredCapability).filter(Boolean).map(String));
14
18
  const assignedAgents = new Set(plan.map((task) => task?.agentInstanceId).filter(Boolean).map(String));
15
19
  const relevantAgents = agents.filter((agent) => {
@@ -17,12 +21,28 @@ export function resolvePlanConcurrency({ plan = [], agents = [], configured = nu
17
21
  if (id && assignedAgents.has(id)) return true;
18
22
  return (agent?.description?.capabilities ?? []).some((capability) => capabilities.has(String(capability?.id ?? '')));
19
23
  });
20
- const values = [
21
- positiveInteger(configured),
22
- ...plan.flatMap(concurrencyValues),
23
- ...relevantAgents.flatMap(concurrencyValues),
24
- ].filter(Boolean);
25
- return values.length > 0 ? Math.max(1, Math.min(...values)) : DEFAULT_SCHEDULER_CONCURRENCY;
24
+ const ceiling = positiveInteger(configured);
25
+ const agentValues = relevantAgents.flatMap(concurrencyValues).filter(Boolean);
26
+ const taskValues = plan.flatMap(concurrencyValues).filter(Boolean);
27
+ const values = [ceiling, ...taskValues, ...agentValues].filter(Boolean);
28
+ const limit = values.length > 0 ? Math.max(1, Math.min(...values)) : DEFAULT_SCHEDULER_CONCURRENCY;
29
+ const otherMin = [...taskValues, ...agentValues].length > 0
30
+ ? Math.min(...taskValues, ...agentValues)
31
+ : null;
32
+ // The manager ceiling "bit" when it is the (uniquely) binding constraint: it
33
+ // is set, equals the resolved limit, and is strictly below every other input.
34
+ const cappedByCeiling = ceiling != null && limit === ceiling && (otherMin == null || ceiling < otherMin);
35
+ return {
36
+ limit,
37
+ ceiling: ceiling ?? null,
38
+ agentLimit: agentValues.length > 0 ? Math.min(...agentValues) : null,
39
+ taskLimit: taskValues.length > 0 ? Math.min(...taskValues) : null,
40
+ cappedByCeiling,
41
+ };
42
+ }
43
+
44
+ export function resolvePlanConcurrency(options = {}) {
45
+ return describePlanConcurrency(options).limit;
26
46
  }
27
47
 
28
48
  export function resolveCapabilityConcurrency(agent = null, ...constraints) {
@@ -7,6 +7,7 @@ import { createBudgetManager } from './budgetManager.js';
7
7
  import { readyTasks, tasksAwaitingApproval } from './dependencyResolver.js';
8
8
  import { createLockManager } from './lockManager.js';
9
9
  import {
10
+ describePlanConcurrency,
10
11
  effectiveConcurrency,
11
12
  resolveCapabilityConcurrency,
12
13
  resolvePlanConcurrency,
@@ -141,6 +142,28 @@ test('scheduler uses the relevant agent declaration instead of hard-capping plan
141
142
  assert.equal(resolvePlanConcurrency({ plan, agents, configured: 20 }), 10);
142
143
  });
143
144
 
145
+ test('describePlanConcurrency mirrors resolvePlanConcurrency and flags the ceiling', () => {
146
+ const plan = [{ id: 'task-1', requiredCapability: 'ingest' }];
147
+ const agents = [{ description: { capabilities: [{ id: 'ingest' }], limits: { recommendedConcurrency: 10, maxConcurrency: 12 } } }];
148
+
149
+ // The number must never diverge from what the scheduler enforces.
150
+ for (const configured of [undefined, 3, 20]) {
151
+ const opts = configured === undefined ? { plan, agents } : { plan, agents, configured };
152
+ assert.equal(describePlanConcurrency(opts).limit, resolvePlanConcurrency(opts));
153
+ }
154
+
155
+ // Ceiling binds → flagged.
156
+ const capped = describePlanConcurrency({ plan, agents, configured: 3 });
157
+ assert.equal(capped.limit, 3);
158
+ assert.equal(capped.ceiling, 3);
159
+ assert.equal(capped.cappedByCeiling, true);
160
+
161
+ // Ceiling above the agent declaration → does not bind, not flagged.
162
+ const loose = describePlanConcurrency({ plan, agents, configured: 20 });
163
+ assert.equal(loose.limit, 10);
164
+ assert.equal(loose.cappedByCeiling, false);
165
+ });
166
+
144
167
  test('scheduler ignores unrelated agents and falls back to three without declarations', () => {
145
168
  const plan = [{ id: 'task-1', requiredCapability: 'ingest' }];
146
169
  const agents = [{
@@ -11,7 +11,7 @@ import { approvalRequestForTask } from '../orchestrator/approvalPolicy.js';
11
11
  import { PENDING_STATUSES, tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
12
12
  import { assertValidatedFragment } from '../orchestrator/planValidator.js';
13
13
  import { createResultAggregator } from '../orchestrator/resultAggregator.js';
14
- import { drainActive, resolvePlanConcurrency, startReadyTasks } from '../orchestrator/scheduler.js';
14
+ import { describePlanConcurrency, drainActive, startReadyTasks } from '../orchestrator/scheduler.js';
15
15
  import { emitRuntimeLog, pollActivitiesOnce } from './supervisor.js';
16
16
 
17
17
  // 0 by default: automatic replans turn evaluator/replanner TEXT into
@@ -363,11 +363,24 @@ export async function runRuntimeParallelPlan(agent, session, input, {
363
363
  const configuredConcurrency = Number(concurrency) > 0
364
364
  ? Number(concurrency)
365
365
  : Number(process.env.WIKI_MANAGER_CAPABILITY_CONCURRENCY || process.env.WIKI_MANAGER_SCHEDULER_CONCURRENCY);
366
- const limit = resolvePlanConcurrency({
366
+ const concurrencyDetail = describePlanConcurrency({
367
367
  plan: session.headlessPlan ?? [],
368
368
  agents,
369
369
  configured: configuredConcurrency,
370
370
  });
371
+ const limit = concurrencyDetail.limit;
372
+ // Publish the RESOLVED concurrency so both UIs show the real dispatch cap
373
+ // (not a value re-derived from the fragment). Display-only: scheduling still
374
+ // uses `limit` exactly as before. NOTE: this is a plain field assignment — do
375
+ // NOT emit a runtime_log here (it runs before ensurePlanProjection and would
376
+ // clobber session.headlessPlan via applyAgentProjectionToSession). The audit
377
+ // line is folded into the existing "parallel plan enabled" log below.
378
+ session._runConcurrency = {
379
+ limit,
380
+ ceiling: concurrencyDetail.ceiling,
381
+ agentLimit: concurrencyDetail.agentLimit,
382
+ cappedByCeiling: concurrencyDetail.cappedByCeiling,
383
+ };
371
384
  const active = new Map();
372
385
  const attempts = attemptManager ?? createAttemptManager();
373
386
  const assigner = assignmentManager ?? createAssignmentManager({ session });
@@ -392,7 +405,7 @@ export async function runRuntimeParallelPlan(agent, session, input, {
392
405
  };
393
406
  sanitizeSessionPlanForExecution(session, runId);
394
407
  ensurePlanProjection(session, runId);
395
- emitRuntimeLog(session, `scheduler: parallel plan enabled (concurrency ${limit})`);
408
+ emitRuntimeLog(session, `scheduler: parallel plan enabled (concurrency ${limit}; agent=${concurrencyDetail.agentLimit ?? 'n/a'}, ceiling=${concurrencyDetail.ceiling ?? 'none'}${concurrencyDetail.cappedByCeiling ? ' → capped by manager ceiling' : ''})`);
396
409
  // Interactive approvals do NOT expire: the user has /approve, "valide
397
410
  // tout", /cancel and /run kill — an arbitrary timer only created mystery
398
411
  // failures. A deadline exists only when explicitly configured (headless
@@ -534,6 +547,20 @@ export async function runRuntimeParallelPlan(agent, session, input, {
534
547
  workspace: session.workspace ?? null,
535
548
  payload: request,
536
549
  }));
550
+ // Reflect the block on the task's plan step so the UIs can render it
551
+ // distinctly (amber "[⏸]" in the Shell, banner/badge in serve)
552
+ // instead of leaving it as a neutral "pending" indistinguishable
553
+ // from a not-started step. Only promote a plain pending task — never
554
+ // overwrite a status the planner already set (e.g. waiting_approval).
555
+ const currentStatus = String(task.status ?? '').toLowerCase();
556
+ if (!['pending_approval', 'waiting_approval'].includes(currentStatus)) {
557
+ dispatchAgentEvent(session, createAgentEvent('plan_step_updated', {
558
+ origin: 'runtime',
559
+ runId,
560
+ taskId,
561
+ payload: { taskId, status: 'pending_approval' },
562
+ }));
563
+ }
537
564
  }
538
565
  if (newlyRequested.length > 0) {
539
566
  dispatchAgentEvent(session, createAgentEvent('assistant_message', {
@@ -541,6 +541,12 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
541
541
  const listApprovalGrantsByRunStatement = db.prepare(`
542
542
  SELECT * FROM approval_grants WHERE run_id = ? ORDER BY created_at ASC, id ASC
543
543
  `);
544
+ const listPendingApprovalGrantsByRunStatement = db.prepare(`
545
+ SELECT * FROM approval_grants WHERE run_id = ? AND status = 'pending_approval'
546
+ `);
547
+ const markApprovalGrantApprovedStatement = db.prepare(`
548
+ UPDATE approval_grants SET status = 'approved', granted_at = ? WHERE id = ?
549
+ `);
544
550
 
545
551
  function persistEvent(event) {
546
552
  if (NON_PERSISTED_EVENT_TYPES.has(event.type)) return event;
@@ -687,10 +693,12 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
687
693
  const delResults = db.prepare('DELETE FROM task_results WHERE task_id IN (SELECT id FROM tasks WHERE run_id = ?)');
688
694
  const delAssignments = db.prepare('DELETE FROM task_assignments WHERE task_id IN (SELECT id FROM tasks WHERE run_id = ?)');
689
695
  const delAttempts = db.prepare('DELETE FROM task_attempts WHERE run_id = ?');
696
+ const delApprovals = db.prepare('DELETE FROM approval_grants WHERE run_id = ?');
690
697
  for (const runId of runIds) {
691
698
  delResults.run(runId);
692
699
  delAssignments.run(runId);
693
700
  delAttempts.run(runId);
701
+ delApprovals.run(runId);
694
702
  }
695
703
  const events = (workspace
696
704
  ? db.prepare('DELETE FROM events WHERE workspace = ?').run(workspace)
@@ -1020,6 +1028,29 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
1020
1028
  status === 'approved' ? (payload.grantedAt ?? event.ts) : null,
1021
1029
  status === 'rejected' ? (payload.rejectedAt ?? event.ts) : null,
1022
1030
  );
1031
+ // Mirror the in-memory projection's markCoveredApprovalsApproved into the
1032
+ // persisted table: a broad (run/group) grant covers the per-task
1033
+ // pending_approval requests, so mark them approved here too. Without this,
1034
+ // the scheduler proceeds (the projection knows they're covered) but the
1035
+ // approval_grants table keeps them pending_approval forever — orphaned rows
1036
+ // that pile up (32 per run observed) and survive run purges.
1037
+ if (status === 'approved') {
1038
+ const grantedAt = payload.grantedAt ?? event.ts;
1039
+ const grant = {
1040
+ scope: String(payload.scope ?? 'run'),
1041
+ runId: String(runId),
1042
+ workspaceId: event.workspace ?? payload.workspaceId ?? payload.workspace ?? null,
1043
+ planRevision: payload.planRevision == null ? null : Number(payload.planRevision),
1044
+ groupId: payload.groupId ?? null,
1045
+ approvalClasses: normalizeApprovalClasses(payload.approvalClasses ?? payload.approvalClass),
1046
+ ids: new Set([payload.taskId, payload.itemId, payload.approvalId, payload.id, event.taskId]
1047
+ .filter(Boolean).map(String)),
1048
+ };
1049
+ for (const row of listPendingApprovalGrantsByRunStatement.all(String(runId))) {
1050
+ if (String(row.id) === String(id)) continue;
1051
+ if (persistedGrantCovers(grant, row)) markApprovalGrantApprovedStatement.run(grantedAt, row.id);
1052
+ }
1053
+ }
1023
1054
  }
1024
1055
 
1025
1056
  function lifecycleTaskId(event) {
@@ -1177,6 +1208,10 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
1177
1208
  return {
1178
1209
  ...baseState,
1179
1210
  runs,
1211
+ // Resolved scheduler concurrency for the live run (display-only). Null for
1212
+ // replayed/historical state — the UIs then fall back to the plan-derived
1213
+ // value. Never consumed by scheduling.
1214
+ concurrency: session?._runConcurrency ?? null,
1180
1215
  workflow: projectWorkflow({ ...baseState, runs, workspace }, events ?? session?.agentEvents ?? []),
1181
1216
  };
1182
1217
  }
@@ -1281,9 +1316,11 @@ function purgeOldTerminalRuns(db, now = new Date()) {
1281
1316
  db.exec('BEGIN');
1282
1317
  try {
1283
1318
  const deleteEvents = db.prepare('DELETE FROM events WHERE run_id = ? OR json_extract(payload, \'$.runId\') = ?');
1319
+ const deleteApprovals = db.prepare('DELETE FROM approval_grants WHERE run_id = ?');
1284
1320
  const deleteRun = db.prepare('DELETE FROM runs WHERE id = ?');
1285
1321
  for (const runId of oldRuns) {
1286
1322
  deleteEvents.run(runId, runId);
1323
+ deleteApprovals.run(runId);
1287
1324
  deleteRun.run(runId);
1288
1325
  }
1289
1326
  db.exec('COMMIT');
@@ -1403,6 +1440,23 @@ function normalizeApprovalClasses(value) {
1403
1440
  return [...new Set(values.map((item) => String(item).trim()).filter(Boolean))];
1404
1441
  }
1405
1442
 
1443
+ // Persisted-table twin of markCoveredApprovalsApproved (core/agentEvents.js):
1444
+ // does `grant` (already-approved) cover the pending approval_grants `row`?
1445
+ // Kept in sync with that reducer so the DB never disagrees with the projection.
1446
+ function persistedGrantCovers(grant, row) {
1447
+ if (grant.runId != null && row.run_id != null && String(grant.runId) !== String(row.run_id)) return false;
1448
+ if (grant.workspaceId != null && row.workspace_id != null && String(grant.workspaceId) !== String(row.workspace_id)) return false;
1449
+ if (grant.planRevision != null && row.plan_revision != null && Number(grant.planRevision) !== Number(row.plan_revision)) return false;
1450
+ const rowClasses = row.approval_classes ? normalizeApprovalClasses(parseJsonMaybe(row.approval_classes)) : [];
1451
+ if (grant.approvalClasses.length > 0 && rowClasses.length > 0 && !rowClasses.some((item) => grant.approvalClasses.includes(item))) return false;
1452
+ if (grant.scope === 'group' && String(grant.groupId ?? '') !== String(row.group_id ?? '')) return false;
1453
+ if (grant.scope === 'task' || grant.scope === 'tool') {
1454
+ const rowIds = [row.task_id, row.id].filter(Boolean).map(String);
1455
+ if (!rowIds.some((id) => grant.ids.has(id))) return false;
1456
+ }
1457
+ return true;
1458
+ }
1459
+
1406
1460
  function parseJsonMaybe(value) {
1407
1461
  try {
1408
1462
  return JSON.parse(value);
@@ -540,6 +540,48 @@ test('runtime store persists bounded approval grants', () => {
540
540
  store.close();
541
541
  });
542
542
 
543
+ test('runtime store: a run-scope grant marks covered task grants approved (no orphans)', () => {
544
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
545
+ const store = openRuntimeStore({ stateDir });
546
+ // Two per-task approval requests for the same run.
547
+ for (const taskId of ['run-x:ingest-a', 'run-x:ingest-b']) {
548
+ store.persistEvent(createAgentEvent('approval.requested', {
549
+ origin: 'runtime',
550
+ runId: 'run-x',
551
+ workspace: 'docs',
552
+ taskId,
553
+ payload: { id: `approval:${taskId}`, scope: 'task', runId: 'run-x', workspaceId: 'docs', taskId },
554
+ }));
555
+ }
556
+ assert.equal(store.listApprovalGrants({ runId: 'run-x' }).filter((g) => g.status === 'pending_approval').length, 2);
557
+
558
+ // A single run-scope grant covers both.
559
+ store.persistEvent(createAgentEvent('approval.granted', {
560
+ origin: 'runtime',
561
+ runId: 'run-x',
562
+ workspace: 'docs',
563
+ payload: { id: 'grant-run-x', scope: 'run', runId: 'run-x', workspaceId: 'docs' },
564
+ }));
565
+ const grants = store.listApprovalGrants({ runId: 'run-x' });
566
+ assert.equal(grants.filter((g) => g.status === 'pending_approval').length, 0, 'no task grant left pending');
567
+ assert.equal(grants.filter((g) => g.status === 'approved').length, 3, 'both tasks + run grant approved');
568
+ store.close();
569
+ });
570
+
571
+ test('runtime store: clearWorkspaceState removes approval grants', () => {
572
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
573
+ const store = openRuntimeStore({ stateDir });
574
+ store.persistEvent(createAgentEvent('run_started', { origin: 'runtime', runId: 'run-y', workspace: 'docs', payload: { runId: 'run-y' } }));
575
+ store.persistEvent(createAgentEvent('approval.requested', {
576
+ origin: 'runtime', runId: 'run-y', workspace: 'docs', taskId: 'run-y:t',
577
+ payload: { id: 'approval:run-y:t', scope: 'task', runId: 'run-y', workspaceId: 'docs', taskId: 'run-y:t' },
578
+ }));
579
+ assert.equal(store.listApprovalGrants({ runId: 'run-y' }).length, 1);
580
+ store.clearWorkspaceState({ workspace: 'docs' });
581
+ assert.equal(store.listApprovalGrants({ runId: 'run-y' }).length, 0);
582
+ store.close();
583
+ });
584
+
543
585
  test('runtime store orders events by durable sequence', () => {
544
586
  const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
545
587
  const store = openRuntimeStore({ stateDir });
@@ -145,11 +145,16 @@ function activityPercentBadge(activity: any): { text: string; bg: string; fg: st
145
145
  // pending icon). Shared by planStepColor and PlanPanel's icon().
146
146
  const DONE_STATUSES = ['done', 'complete', 'completed', 'success', 'succeeded'];
147
147
  const FAILED_STATUSES = ['failed', 'error'];
148
+ // A task blocked on a human decision — distinct from both running and pending,
149
+ // so it must not render as a neutral gray "[ ]" (indistinguishable from
150
+ // not-started) nor as a running step.
151
+ const APPROVAL_STATUSES = ['pending_approval', 'waiting_approval'];
148
152
 
149
153
  function planStepColor(step: PlanStep, firstPendingStep: number | null) {
150
154
  const status = String(step.status ?? '').toLowerCase();
151
155
  if (DONE_STATUSES.includes(status)) return '#8BD5CA';
152
156
  if (FAILED_STATUSES.includes(status)) return '#F38BA8';
157
+ if (APPROVAL_STATUSES.includes(status)) return '#FBBF24';
153
158
  if (status === 'running') return '#89B4FA';
154
159
  if (step.step === firstPendingStep) return '#89B4FA';
155
160
  return '#7F8C8D';
@@ -175,7 +180,7 @@ function queueSummary(item: QueueItem) {
175
180
  return parts.join(' ');
176
181
  }
177
182
 
178
- export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: string }) {
183
+ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: string; summary?: string | null; spinnerFrame?: string }) {
179
184
  // Keep one column for the native vertical scrollbar when the plan is long.
180
185
  const lineWidth = () => Math.max(8, props.width - 3);
181
186
  const firstPending = () => props.plan.find((s) => s.status === 'pending')?.step ?? null;
@@ -183,7 +188,11 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
183
188
  const status = String(rawStatus ?? '').toLowerCase();
184
189
  if (DONE_STATUSES.includes(status)) return '[✓]';
185
190
  if (FAILED_STATUSES.includes(status)) return '[✗]';
186
- return status === 'running' ? '[…]' : '[ ]';
191
+ if (APPROVAL_STATUSES.includes(status)) return '[⏸]';
192
+ // Animated braille spinner on the running step: a long production job (LLM
193
+ // building a template) otherwise leaves the whole plan visually frozen. Same
194
+ // frames as the chat spinner. Falls back to a static glyph if not supplied.
195
+ return status === 'running' ? `[${props.spinnerFrame ?? '…'}]` : '[ ]';
187
196
  };
188
197
  const visualRows = createMemo(() => props.plan.reduce((total, step) =>
189
198
  total + wrapLine(`${icon(step.status)} ${step.step}. ${step.description}`, lineWidth()).slice(0, 2).length, 0));
@@ -192,9 +201,17 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
192
201
  return visualRows() > PLAN_VIEWPORT_ROWS ? `${label} (${props.plan.length}) · scroll` : label;
193
202
  };
194
203
  const viewportRows = () => Math.min(PLAN_VIEWPORT_ROWS, Math.max(1, visualRows()));
204
+ const summaryLines = () => props.summary ? wrapLine(props.summary, lineWidth()).slice(0, 2) : [];
195
205
  return (
196
206
  <box flexShrink={0} flexDirection="column" padding={1}>
197
207
  <text width={lineWidth()} fg="#D6DEE8" content={fit(title(), lineWidth())} />
208
+ <Show when={summaryLines().length > 0}>
209
+ <box flexShrink={0} flexDirection="column">
210
+ <Index each={summaryLines()}>
211
+ {(line) => <text width={lineWidth()} fg="#89B4FA" content={line()} />}
212
+ </Index>
213
+ </box>
214
+ </Show>
198
215
  <scrollbox
199
216
  height={viewportRows()}
200
217
  focusable={false}
@@ -469,6 +486,7 @@ export function RightPane(props: {
469
486
  activities: any[];
470
487
  logs: string[];
471
488
  plan: PlanStep[] | null;
489
+ runSummary?: string | null;
472
490
  queueItems: QueueItem[];
473
491
  queueInfo: QueueInfo;
474
492
  activeTab: 'plan' | 'queue';
@@ -476,6 +494,7 @@ export function RightPane(props: {
476
494
  pendingApprovals: any[];
477
495
  onApprove: () => void;
478
496
  onTabClick: (tab: 'plan' | 'queue') => void;
497
+ spinnerFrame?: string;
479
498
  }) {
480
499
  const planJobName = () => activityJobName(
481
500
  [...props.activities].reverse().find((activity) => !activity.terminal) ?? props.activities.at(-1),
@@ -501,7 +520,7 @@ export function RightPane(props: {
501
520
  <Show when={props.activeTab === 'queue'} fallback={(
502
521
  <>
503
522
  <Show when={props.plan && props.plan.length > 0}>
504
- <PlanPanel width={props.width} plan={props.plan!} jobName={planJobName()} />
523
+ <PlanPanel width={props.width} plan={props.plan!} jobName={planJobName()} summary={props.runSummary} spinnerFrame={props.spinnerFrame} />
505
524
  </Show>
506
525
  <ActivityPanel width={props.width} activities={props.activities} />
507
526
  </>
@@ -100,6 +100,19 @@ test('ShellUI lets the right pane extend to the terminal edge', async () => {
100
100
  assert.doesNotMatch(pane, /height="100%" flexDirection="column" padding=\{1\}/);
101
101
  });
102
102
 
103
+ test('ShellUI shows the canonical run summary above the plan', async () => {
104
+ const session = await readFile(new URL('./useSession.ts', import.meta.url), 'utf8');
105
+ const pane = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
106
+ const tui = await readFile(new URL('./tui.tsx', import.meta.url), 'utf8');
107
+ assert.match(session, /const runSummary = createMemo/);
108
+ assert.match(session, /agent\$\{agents\.size === 1/);
109
+ assert.match(session, /parallel \$\{activeParallel\}\/×\$\{maxParallel\}/);
110
+ assert.match(session, /usage\.inputKnown/);
111
+ assert.match(session, /usage\.outputKnown/);
112
+ assert.match(pane, /summary=\{props\.runSummary\}/);
113
+ assert.match(tui, /runSummary=\{state\.runSummary\(\)\}/);
114
+ });
115
+
103
116
  test('Flow/Trace does not repeat the runtime source prefix on every line', async () => {
104
117
  const source = await readFile(new URL('./RightPane.tsx', import.meta.url), 'utf8');
105
118
  const entryRenderer = source.slice(
package/src/shell/tui.tsx CHANGED
@@ -287,7 +287,10 @@ function App(props: {
287
287
  });
288
288
 
289
289
  const spinnerTimer = setInterval(() => {
290
- if (state.busy()) setSpinnerIndex((value) => (value + 1) % 10);
290
+ // Advance while the chat is streaming OR a runtime run is executing. Without
291
+ // executionActive(), the plan spinner froze on frame 0 during a scheduler-
292
+ // driven run (no conversation turn), so it looked static.
293
+ if (state.busy() || state.executionActive()) setSpinnerIndex((value) => (value + 1) % 10);
291
294
  }, 90);
292
295
  onCleanup(() => {
293
296
  clearInterval(spinnerTimer);
@@ -392,6 +395,7 @@ function App(props: {
392
395
  activities={state.activities()}
393
396
  logs={state.logs()}
394
397
  plan={state.plan()}
398
+ runSummary={state.runSummary()}
395
399
  queueItems={state.queueItems()}
396
400
  queueInfo={state.queueInfo()}
397
401
  activeTab={state.rightTab()}
@@ -399,6 +403,7 @@ function App(props: {
399
403
  pendingApprovals={state.pendingApprovals()}
400
404
  onApprove={() => { void state.submitInput('/approve'); }}
401
405
  onTabClick={state.selectRightTab}
406
+ spinnerFrame={SPINNER_FRAMES[spinnerIndex()] ?? SPINNER_FRAMES[0]}
402
407
  />
403
408
  <SlashDialog context={state.activeEditor() ? null : state.slash()} />
404
409
  <FileEditorDialog
@@ -321,6 +321,52 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
321
321
  ? lastVisiblePlan.map((step) => ({ ...step }))
322
322
  : current;
323
323
  });
324
+ const runSummary = createMemo(() => {
325
+ version();
326
+ const workflow = runtimeState()?.workflow;
327
+ const tasks = Array.isArray(workflow?.nodes)
328
+ ? workflow.nodes.filter((node: any) => node.type === 'task')
329
+ : [];
330
+ if (tasks.length === 0) return null;
331
+
332
+ const graphNodes = Array.isArray(workflow?.graph?.nodes) ? workflow.graph.nodes : [];
333
+ const graphEdges = Array.isArray(workflow?.graph?.edges) ? workflow.graph.edges : [];
334
+ const agents = new Set<string>();
335
+ for (const task of tasks) {
336
+ if (task.executor) agents.add(String(task.executor));
337
+ const assignmentIds = graphEdges
338
+ .filter((edge: any) => edge.type === 'assigned_to' && edge.from === task.id)
339
+ .map((edge: any) => edge.to);
340
+ for (const assignmentId of assignmentIds) {
341
+ graphEdges
342
+ .filter((edge: any) => edge.type === 'uses_agent' && edge.from === assignmentId)
343
+ .forEach((edge: any) => agents.add(String(edge.to).replace(/^agent:/, '')));
344
+ }
345
+ }
346
+
347
+ const activeParallel = tasks.filter((task: any) => String(task.status) === 'running').length;
348
+ const groupConcurrency = graphNodes
349
+ .filter((node: any) => node.type === 'task_group')
350
+ .map((node: any) => Number(node.raw?.recommendedConcurrency))
351
+ .filter((value: number) => Number.isFinite(value) && value > 0);
352
+ // Authoritative resolved concurrency published by the runtime; fall back to
353
+ // the plan-derived value for replayed/historical runs.
354
+ const resolved = runtimeState()?.concurrency;
355
+ const maxParallel = Number.isFinite(Number(resolved?.limit))
356
+ ? Number(resolved.limit)
357
+ : Math.max(1, activeParallel, ...groupConcurrency);
358
+ const done = tasks.filter((task: any) => String(task.status) === 'done').length;
359
+ const usage = workflow?.usage ?? {};
360
+ const tokens = (known: unknown, value: unknown) =>
361
+ known ? new Intl.NumberFormat().format(Number(value) || 0) : '—';
362
+
363
+ return [
364
+ `${agents.size} agent${agents.size === 1 ? '' : 's'}`,
365
+ `parallel ${activeParallel}/×${maxParallel}${resolved?.cappedByCeiling ? ' (ceiling)' : ''}`,
366
+ `${done}/${tasks.length} tasks`,
367
+ `${tokens(usage.inputKnown, usage.inputTokens)} in · ${tokens(usage.outputKnown, usage.outputTokens)} out`,
368
+ ].join(' · ');
369
+ });
324
370
  const visibleLogs = createMemo(() => {
325
371
  version();
326
372
  const runtimeLogs = runtimeState()?.logs;
@@ -728,6 +774,7 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
728
774
  toggleRightTab,
729
775
  selectRightTab,
730
776
  plan,
777
+ runSummary,
731
778
  pendingApprovals,
732
779
  conversationScroll,
733
780
  scrollConversation,