@dotdrelle/wiki-manager 0.15.49 → 0.15.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -3
- package/docker-compose.yml +7 -1
- package/package.json +1 -1
- package/src/agent/graph.js +4 -36
- package/src/agent/graph.test.js +12 -54
- package/src/cli/wiki-manager.js +28 -4
- package/src/commands/slash.js +1 -1
- package/src/core/agentEvents.js +12 -1
- package/src/core/agentEvents.test.js +21 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/dockerCompose.test.js +18 -0
- package/src/core/mcp.js +1 -1
- package/src/orchestrator/.fuse_hidden0000001c00000001 +316 -0
- package/src/orchestrator/agentRegistry.js +54 -0
- package/src/orchestrator/agentRegistry.test.js +75 -0
- package/src/runtime/client.js +2 -0
- package/src/runtime/delegation.js +1 -1
- package/src/runtime/runner.js +3 -3
- package/src/runtime/server.js +5 -4
- package/src/runtime/server.test.js +12 -1
- package/src/runtime/supervisor.js +18 -1
- package/src/runtime/supervisor.test.js +43 -0
- package/src/shell/LeftPane.tsx +17 -5
- package/src/shell/StartupScreen.tsx +9 -4
- package/src/shell/repl.js +1 -0
package/README.md
CHANGED
|
@@ -637,9 +637,15 @@ with `"replans": 1` in the `/run` body.
|
|
|
637
637
|
Runtime approvals are bounded to a run, plan revision and approval class.
|
|
638
638
|
Mutating orchestrated tasks **wait for approval by default**, including tasks
|
|
639
639
|
created by a skill or a directly selected capability such as ingest or
|
|
640
|
-
pipeline. Approve them by
|
|
641
|
-
|
|
642
|
-
|
|
640
|
+
pipeline. Approve them by running `/approve`, or clicking Approve in either UI:
|
|
641
|
+
the Shell right-pane banner, or the `serve` banner.
|
|
642
|
+
|
|
643
|
+
In `serve` that banner is a **fixed overlay, visible in every centre view**. It
|
|
644
|
+
used to sit inside the composer, which the layout hides in the wiki, connectors
|
|
645
|
+
and execution views — so a restore launched from `/history` waited on an
|
|
646
|
+
approval nobody could see, and the Execution view, the one meant for monitoring,
|
|
647
|
+
could not show it either. The Shell never had that gap because its plan pane is
|
|
648
|
+
always on screen. `POST /approve` also accepts an explicit run scope.
|
|
643
649
|
|
|
644
650
|
An explicitly launched skill is also approval-gated. For an orchestrated skill,
|
|
645
651
|
the scheduler blocks each uncovered mutating task; for a `direct` skill, the
|
|
@@ -907,10 +913,35 @@ The shared `docker-compose.yml` starts one workspace stack:
|
|
|
907
913
|
Use `wiki-workspace` whenever possible so Compose receives the right project
|
|
908
914
|
name, env file, ports, and volume mounts.
|
|
909
915
|
|
|
916
|
+
`PRODUCTION_ALLOWED_STEPS` gates what `production-mcp` will accept, and an
|
|
917
|
+
omission from it is **silent**: `agent_plan` simply leaves the step's task out of
|
|
918
|
+
the fragment instead of failing. `taxonomy` was missing from the shipped default
|
|
919
|
+
for several releases, so every compose-deployed ingest ran without the taxonomy
|
|
920
|
+
barrier and left the published map stale. Keep the variable in step with the
|
|
921
|
+
in-code default of `production_mcp_server.py`: a test here asserts `taxonomy` is
|
|
922
|
+
present, and one in `agent-wiki-production` compares the whole list against that
|
|
923
|
+
in-code reference. Remember that an explicit value in your `.env` overrides the
|
|
924
|
+
default entirely.
|
|
925
|
+
|
|
910
926
|
Runtime split: the host manager/runtime uses Node.js 22+ for `node:sqlite`; the
|
|
911
927
|
interactive OpenTUI shell uses Bun 1.2+; workspace Docker services run from the
|
|
912
928
|
published images and do not depend on host `node_modules`.
|
|
913
929
|
|
|
930
|
+
Two consequences worth knowing before debugging anything:
|
|
931
|
+
|
|
932
|
+
- **The runtime is not a container.** `runtime/lifecycle.js` spawns it locally,
|
|
933
|
+
detached, from the manager sources — no Compose file declares it. Changing
|
|
934
|
+
runtime or shell code therefore needs a **restart**, never an image rebuild;
|
|
935
|
+
changing `llm-wiki` or an agent needs the image rebuilt.
|
|
936
|
+
- **The runtime starts before the workspace containers.** Its first agent
|
|
937
|
+
discovery legitimately finds them absent. `agentRegistry` keeps a known
|
|
938
|
+
agent's capabilities when a probe fails — it only refreshes `lastSeenAt`, and
|
|
939
|
+
says so in the runtime log — and the periodic re-scan re-probes the MCP
|
|
940
|
+
endpoints instead of reusing a cached status. Without both, a capability the
|
|
941
|
+
agent really has stayed missing from the registry until the next successful
|
|
942
|
+
discovery, and the only symptom was a run failing much later with
|
|
943
|
+
`No agent provides capability …`.
|
|
944
|
+
|
|
914
945
|
As of 0.11.4, the host runtime store carries a minimal format guard:
|
|
915
946
|
`PRAGMA user_version = 1` in SQLite plus `.wiki/meta.json` with
|
|
916
947
|
`schemaVersion: 1`. Unknown future versions stop startup with a clear error.
|
package/docker-compose.yml
CHANGED
|
@@ -124,7 +124,13 @@ services:
|
|
|
124
124
|
- WORKSPACE_NAME=${WORKSPACE_NAME:-workspace}
|
|
125
125
|
- WIKI_WORKSPACE_PATH=/workspace
|
|
126
126
|
- WIKI_CONFIG_PATH=${WIKI_CONFIG_PATH:-}
|
|
127
|
-
|
|
127
|
+
# `taxonomy` belongs here, and its absence is SILENT: agent_plan drops the
|
|
128
|
+
# taxonomy task from an ingest fragment when the step is not allowed
|
|
129
|
+
# (production_mcp_server.py, "taxonomy" in _ALLOWED_STEPS), without an
|
|
130
|
+
# error. Every compose-deployed ingest then ran without the Lot 4 barrier
|
|
131
|
+
# and left the published map stale — the very defect that work fixed.
|
|
132
|
+
# `copy` stays out on purpose: it is the legacy step, opt-in only.
|
|
133
|
+
- PRODUCTION_ALLOWED_STEPS=${PRODUCTION_ALLOWED_STEPS:-doctor,ingest,ingest_plan,ingest_apply,taxonomy,build,export,polish,restore,pipeline}
|
|
128
134
|
- PRODUCTION_REQUIRE_CONFIRMATION=${PRODUCTION_REQUIRE_CONFIRMATION:-false}
|
|
129
135
|
# Parallelism levers — effective concurrency ≈ recommendedConcurrency.
|
|
130
136
|
# Intermediate defaults (4/8). Low profile 2/4, high profile 8/16.
|
package/package.json
CHANGED
package/src/agent/graph.js
CHANGED
|
@@ -26,7 +26,7 @@ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
|
26
26
|
import { enqueueProductionJob, ensureJobQueue, formatQueue, productionLockBusy } from '../core/jobQueue.js';
|
|
27
27
|
import { loadWorkspaceProfile, updateWorkspaceProfilePreference } from '../core/profile.js';
|
|
28
28
|
import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
|
|
29
|
-
import { fetchRuntimeState,
|
|
29
|
+
import { fetchRuntimeState, postRuntimeCancel, postRuntimeControl, postRuntimeDelegate, postRuntimeKill, postRuntimeSkill } from '../runtime/client.js';
|
|
30
30
|
import { controlLanguage } from '../runtime/controlMessages.js';
|
|
31
31
|
|
|
32
32
|
const MAX_TOOL_ITERATIONS = 80;
|
|
@@ -46,7 +46,7 @@ const MAX_SPINNER_ARG_LENGTH = 96;
|
|
|
46
46
|
const INTERNAL_TOOL_SERVERS = {
|
|
47
47
|
wiki: ['plan_set', 'plan_done'],
|
|
48
48
|
shell: ['run_command', 'read_command', 'profile_update'],
|
|
49
|
-
runtime: ['kill', 'cancel', 'status', '
|
|
49
|
+
runtime: ['kill', 'cancel', 'status', 'enqueue', 'delegate', 'run_skill'],
|
|
50
50
|
};
|
|
51
51
|
|
|
52
52
|
const AGENT_SLASH_COMMANDS = new Set([
|
|
@@ -164,15 +164,6 @@ const RUNTIME_STATUS_TOOL = {
|
|
|
164
164
|
},
|
|
165
165
|
};
|
|
166
166
|
|
|
167
|
-
const RUNTIME_APPROVE_TOOL = {
|
|
168
|
-
type: 'function',
|
|
169
|
-
function: {
|
|
170
|
-
name: 'runtime__approve',
|
|
171
|
-
description: 'Grant the pending approval of the active runtime run (mutating tasks wait on it). Use when the user consents in ANY phrasing: "vas-y", "ok pour l\'export", "approuve", "valide". Confirm what was approved.',
|
|
172
|
-
parameters: { type: 'object', additionalProperties: false, properties: {} },
|
|
173
|
-
},
|
|
174
|
-
};
|
|
175
|
-
|
|
176
167
|
const RUNTIME_ENQUEUE_TOOL = {
|
|
177
168
|
type: 'function',
|
|
178
169
|
function: {
|
|
@@ -364,7 +355,6 @@ function toolDefinitionForCall(session, callName) {
|
|
|
364
355
|
RUNTIME_STATUS_TOOL,
|
|
365
356
|
RUNTIME_CANCEL_TOOL,
|
|
366
357
|
RUNTIME_KILL_TOOL,
|
|
367
|
-
RUNTIME_APPROVE_TOOL,
|
|
368
358
|
RUNTIME_ENQUEUE_TOOL,
|
|
369
359
|
RUNTIME_DELEGATE_TOOL,
|
|
370
360
|
RUNTIME_RUN_SKILL_TOOL,
|
|
@@ -852,28 +842,6 @@ export async function handleRuntimeControlTool(session, tool, args = {}) {
|
|
|
852
842
|
const result = await postRuntimeCancel({ url, workspace });
|
|
853
843
|
return result.cancelled ? 'Runtime run cancellation requested.' : `No active run to cancel${result.reason ? ` (${result.reason})` : ''}.`;
|
|
854
844
|
}
|
|
855
|
-
if (tool === 'approve') {
|
|
856
|
-
const state = await fetchRuntimeState({ url, workspace });
|
|
857
|
-
const pending = (Array.isArray(state?.approvals) ? state.approvals : [])
|
|
858
|
-
.filter((approval) => approval.status === 'pending_approval');
|
|
859
|
-
const runId = state?.runId
|
|
860
|
-
?? state?.runs?.find((run) => ['running', 'pending_approval'].includes(run.status))?.id
|
|
861
|
-
?? null;
|
|
862
|
-
if (!runId || pending.length === 0) return 'No pending approval found.';
|
|
863
|
-
const approvalClasses = [...new Set(pending.flatMap((approval) => {
|
|
864
|
-
const value = approval.approvalClasses ?? approval.approvalClass ?? [];
|
|
865
|
-
return Array.isArray(value) ? value : [value];
|
|
866
|
-
}).map(String).filter(Boolean))];
|
|
867
|
-
const result = await postRuntimeApprove({
|
|
868
|
-
url,
|
|
869
|
-
workspace,
|
|
870
|
-
runId,
|
|
871
|
-
scope: 'run',
|
|
872
|
-
planRevision: state?.planRevision ?? null,
|
|
873
|
-
approvalClasses: approvalClasses.length > 0 ? approvalClasses : ['default'],
|
|
874
|
-
});
|
|
875
|
-
return result?.approved ? 'Current validated plan approved.' : 'No pending approval found.';
|
|
876
|
-
}
|
|
877
845
|
if (tool === 'delegate') {
|
|
878
846
|
const objective = String(args.objective ?? '').trim();
|
|
879
847
|
if (!objective) return 'Delegation rejected: missing objective.';
|
|
@@ -1250,7 +1218,7 @@ export function buildAgentSystemPrompt(state) {
|
|
|
1250
1218
|
workspaceProfile
|
|
1251
1219
|
? `Workspace profile (.wiki/profile.md) — durable user preferences, apply these to every reply (tone, tutoiement/vouvoiement, formatting, etc.):\n${workspaceProfile}`
|
|
1252
1220
|
: null,
|
|
1253
|
-
'Runtime control: you have runtime__status, runtime__cancel, runtime__kill
|
|
1221
|
+
'Runtime control: you have runtime__status, runtime__cancel, runtime__kill and runtime__enqueue. When the user asks to stop, remove, clean or kill the current run, its jobs or the queue ("supprime le job et la queue", "arr\u00eate tout"), call runtime__kill (or runtime__cancel for a soft stop of just the run) and confirm what was stopped. When the user explicitly asks to delete, reset, abandon or replace the current plan, call runtime__kill with purge=true; never set purge=true for a simple stop. For questions about what is running or queued, call runtime__status and answer from its data. You have no approval tool: a pending approval is granted only by the user through the approval button or the /approve command. Never grant, claim or report an approval yourself; when the user asks to proceed with pending mutations, tell them to use those controls. When the user asks for a NEW action while a run is active, do not execute it: propose runtime__enqueue (run it after) or, if they insist it replaces the current work, runtime__kill then the new action.',
|
|
1254
1222
|
'When the user asks to refresh, show, or update the displayed plan or status, call runtime__status. This is a state refresh request, not a new business capability, and must never be delegated.',
|
|
1255
1223
|
'Report every runtime control outcome exactly as the tool returned it \u2014 never embellish. If runtime__kill reports 0 run(s)/0 task(s)/0 purged, say there was nothing active to stop or purge; do NOT claim a run, plan, pending approval or queue item was removed. If runtime__status returns an error or could not be read, say the runtime state could not be retrieved and do not describe a state you never obtained. Never assert that something was cleaned, cancelled, approved or purged unless that specific tool result confirms it.',
|
|
1256
1224
|
'Durable profile updates are actions in this stabilized version: delegate them instead of writing directly.',
|
|
@@ -1285,7 +1253,7 @@ export function formatLlmUnavailableMessage(reason) {
|
|
|
1285
1253
|
|
|
1286
1254
|
function toolsForClassification(classification, writeTools, session = null) {
|
|
1287
1255
|
const controlTools = session?.runtime?.url
|
|
1288
|
-
? [RUNTIME_STATUS_TOOL, RUNTIME_CANCEL_TOOL, RUNTIME_KILL_TOOL,
|
|
1256
|
+
? [RUNTIME_STATUS_TOOL, RUNTIME_CANCEL_TOOL, RUNTIME_KILL_TOOL, RUNTIME_ENQUEUE_TOOL]
|
|
1289
1257
|
: [];
|
|
1290
1258
|
// Provider discovery and validation belong to the runtime. Hiding
|
|
1291
1259
|
// delegation while the shell snapshot is temporarily empty forced Donna
|
package/src/agent/graph.test.js
CHANGED
|
@@ -782,67 +782,25 @@ test('runtime status does not manufacture a plan', async () => {
|
|
|
782
782
|
}
|
|
783
783
|
});
|
|
784
784
|
|
|
785
|
-
test('
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
if ((options.method ?? 'GET') === 'GET') {
|
|
791
|
-
return {
|
|
792
|
-
ok: true,
|
|
793
|
-
status: 200,
|
|
794
|
-
json: async () => ({
|
|
795
|
-
running: true,
|
|
796
|
-
runId: 'run-approval',
|
|
797
|
-
planRevision: 3,
|
|
798
|
-
approvals: [
|
|
799
|
-
{ status: 'pending_approval', approvalClasses: ['workspace'] },
|
|
800
|
-
{ status: 'pending_approval', approvalClasses: ['workspace'] },
|
|
801
|
-
],
|
|
802
|
-
}),
|
|
803
|
-
};
|
|
804
|
-
}
|
|
805
|
-
return { ok: true, status: 202, json: async () => ({ approved: true, runId: 'run-approval' }) };
|
|
806
|
-
};
|
|
807
|
-
let calls = 0;
|
|
785
|
+
test('Donna has no self-approval tool during an active run', async () => {
|
|
786
|
+
// Donna must never grant her own pending approval (the recurring "spontaneous
|
|
787
|
+
// approval" regression). Approval is the user's action, expressed through the
|
|
788
|
+
// banner button or /approve — never through an LLM tool call.
|
|
789
|
+
let seenTools = [];
|
|
808
790
|
const session = sessionBase({
|
|
809
791
|
runtime: { url: 'http://runtime.test' },
|
|
810
792
|
agentProjection: { status: 'running', conversation: [], activities: [] },
|
|
811
793
|
llm: {
|
|
812
|
-
async completeWithTools() {
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
return {
|
|
816
|
-
content: null,
|
|
817
|
-
message: { role: 'assistant', content: null },
|
|
818
|
-
tool_calls: [{
|
|
819
|
-
id: 'approve-run',
|
|
820
|
-
type: 'function',
|
|
821
|
-
function: { name: 'runtime__approve', arguments: '{}' },
|
|
822
|
-
}],
|
|
823
|
-
};
|
|
824
|
-
}
|
|
825
|
-
return { content: 'Plan approuvé.', message: { role: 'assistant', content: 'Plan approuvé.' }, tool_calls: null };
|
|
794
|
+
async completeWithTools({ tools }) {
|
|
795
|
+
seenTools = tools.map((tool) => tool.function.name);
|
|
796
|
+
return { content: 'ok', message: { role: 'assistant', content: 'ok' }, tool_calls: null };
|
|
826
797
|
},
|
|
827
798
|
},
|
|
828
799
|
});
|
|
829
800
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
const approval = requests.find((request) => request.url.includes('/approve'));
|
|
834
|
-
assert.deepEqual(approval.body, {
|
|
835
|
-
workspace: 'docs',
|
|
836
|
-
runId: 'run-approval',
|
|
837
|
-
itemId: null,
|
|
838
|
-
approvalId: null,
|
|
839
|
-
scope: 'run',
|
|
840
|
-
planRevision: 3,
|
|
841
|
-
approvalClasses: ['workspace'],
|
|
842
|
-
});
|
|
843
|
-
} finally {
|
|
844
|
-
globalThis.fetch = originalFetch;
|
|
845
|
-
}
|
|
801
|
+
await createAgentGraph().invoke({ input: 'oui', session });
|
|
802
|
+
assert.ok(seenTools.includes('runtime__status'), 'control tools still bound during an active run');
|
|
803
|
+
assert.ok(!seenTools.includes('runtime__approve'), 'no runtime__approve tool for Donna');
|
|
846
804
|
});
|
|
847
805
|
|
|
848
806
|
test('agent graph binds the full toolset for a "remember my preference" request, not just read-only tools', async () => {
|
|
@@ -1773,7 +1731,7 @@ test('agent graph lets Donna handle ambiguous input during a run with the contro
|
|
|
1773
1731
|
assert.match(result.response, /mettre en file/);
|
|
1774
1732
|
assert.ok(seenTools.includes('runtime__enqueue'));
|
|
1775
1733
|
assert.ok(seenTools.includes('runtime__status'));
|
|
1776
|
-
assert.ok(seenTools.includes('runtime__approve'));
|
|
1734
|
+
assert.ok(!seenTools.includes('runtime__approve'), 'no self-approval tool for Donna');
|
|
1777
1735
|
assert.ok(!seenTools.includes('production__production_start_job'), 'no write MCP tools during an active run for ambiguous intents');
|
|
1778
1736
|
});
|
|
1779
1737
|
|
package/src/cli/wiki-manager.js
CHANGED
|
@@ -567,6 +567,7 @@ async function waitForRuntimeRun(session, log, { timeoutMs, pollMs = 1500, autoA
|
|
|
567
567
|
scope: 'run',
|
|
568
568
|
planRevision,
|
|
569
569
|
approvalClasses: approvalClasses.length > 0 ? approvalClasses : ['default'],
|
|
570
|
+
caller: 'headless-auto-approve',
|
|
570
571
|
});
|
|
571
572
|
const line = `runtime-wait: auto-approved run ${currentRun.id} (revision ${planRevision})${result?.approved ? '' : ' [no pending approval matched]'}`;
|
|
572
573
|
log.push(line); console.log(line);
|
|
@@ -845,6 +846,7 @@ export async function waitForRuntimeChain(session, log, {
|
|
|
845
846
|
scope: 'run',
|
|
846
847
|
planRevision,
|
|
847
848
|
approvalClasses: approvalClasses.length > 0 ? approvalClasses : ['default'],
|
|
849
|
+
caller: 'chain-wait-auto-approve',
|
|
848
850
|
});
|
|
849
851
|
const line = `chain-wait: auto-approved run ${active.runId} (revision ${planRevision})`;
|
|
850
852
|
log.push(line); console.log(line);
|
|
@@ -978,11 +980,12 @@ async function runRuntime(argv, agent) {
|
|
|
978
980
|
payload: { message: `runtime: expired ${staleControl.length} stale queued control request(s) from a previous session` },
|
|
979
981
|
}));
|
|
980
982
|
}
|
|
981
|
-
session._onRuntimeError = (err) => {
|
|
983
|
+
session._onRuntimeError = (err, runId = null) => {
|
|
982
984
|
const message = err instanceof Error ? err.message : String(err);
|
|
983
985
|
dispatchAgentEvent(session, createAgentEvent('run_error', {
|
|
984
986
|
origin: 'runtime',
|
|
985
|
-
|
|
987
|
+
...(runId ? { runId } : {}),
|
|
988
|
+
payload: { message, workspace, ...(runId ? { runId } : {}) },
|
|
986
989
|
}));
|
|
987
990
|
};
|
|
988
991
|
context.approvalManager = createApprovalManager(session, {
|
|
@@ -991,7 +994,9 @@ async function runRuntime(argv, agent) {
|
|
|
991
994
|
: undefined,
|
|
992
995
|
});
|
|
993
996
|
session._requestApproval = (request) => context.approvalManager.requestApproval(request);
|
|
994
|
-
context.supervisor = startActivitySupervisor(session
|
|
997
|
+
context.supervisor = startActivitySupervisor(session, {
|
|
998
|
+
refreshMcp: () => refreshMcpRuntimeStatus(session),
|
|
999
|
+
});
|
|
995
1000
|
contexts.set(key, context);
|
|
996
1001
|
if (workspace && workspace !== key) contexts.set(workspace, context);
|
|
997
1002
|
return context;
|
|
@@ -1422,7 +1427,26 @@ async function runRuntime(argv, agent) {
|
|
|
1422
1427
|
const provider = agents.find((item) => (item.description?.capabilities ?? [])
|
|
1423
1428
|
.some((capability) => capability.id === body.capabilityPlan.capability));
|
|
1424
1429
|
if (!provider?.serverName) {
|
|
1425
|
-
|
|
1430
|
+
/*
|
|
1431
|
+
Say what WAS seen, not only what was missing.
|
|
1432
|
+
|
|
1433
|
+
"No agent provides capability X." leaves three very different causes
|
|
1434
|
+
indistinguishable: no agent registered at all, the right agent
|
|
1435
|
+
registered but advertising a narrower set (an operation excluded by
|
|
1436
|
+
PRODUCTION_ALLOWED_STEPS drops its whole capability from
|
|
1437
|
+
agent_describe, silently), or a name mismatch. Listing the registry
|
|
1438
|
+
turns the next occurrence into its own diagnosis instead of a guess.
|
|
1439
|
+
*/
|
|
1440
|
+
const seen = agents.map((item) => {
|
|
1441
|
+
const ids = (item.description?.capabilities ?? []).map((capability) => capability.id);
|
|
1442
|
+
return `${item.serverName ?? '?'}[${ids.join(', ') || 'none'}]`;
|
|
1443
|
+
});
|
|
1444
|
+
throw new Error(
|
|
1445
|
+
`No agent provides capability ${body.capabilityPlan.capability}. `
|
|
1446
|
+
+ (seen.length
|
|
1447
|
+
? `Registered agents: ${seen.join('; ')}.`
|
|
1448
|
+
: 'No agent is registered: none answered agent_describe.'),
|
|
1449
|
+
);
|
|
1426
1450
|
}
|
|
1427
1451
|
const fragment = parseJsonText(formatMcpToolResult(await callMcpTool(session.mcp, provider.serverName, 'agent_plan', {
|
|
1428
1452
|
capability: body.capabilityPlan.capability,
|
package/src/commands/slash.js
CHANGED
|
@@ -1402,7 +1402,7 @@ export async function handleSlashCommand(line, context) {
|
|
|
1402
1402
|
},
|
|
1403
1403
|
});
|
|
1404
1404
|
if (result?.runId) {
|
|
1405
|
-
return { output: `▶ Run de capability accepté (${String(result.runId).slice(0, 8)}) — le plan de l'agent sera intégré et dispatché en parallèle ; approbation demandée avant les mutations (
|
|
1405
|
+
return { output: `▶ Run de capability accepté (${String(result.runId).slice(0, 8)}) — le plan de l'agent sera intégré et dispatché en parallèle ; approbation demandée avant les mutations (/approve).` };
|
|
1406
1406
|
}
|
|
1407
1407
|
return { output: `Run non démarré: ${result?.explanation ?? result?.error ?? JSON.stringify(result)}` };
|
|
1408
1408
|
}
|
package/src/core/agentEvents.js
CHANGED
|
@@ -561,7 +561,18 @@ function applyEvent(state, event) {
|
|
|
561
561
|
return;
|
|
562
562
|
case 'run_error':
|
|
563
563
|
state.status = 'error';
|
|
564
|
-
|
|
564
|
+
/*
|
|
565
|
+
"Run failed:" is part of the line, not decoration.
|
|
566
|
+
|
|
567
|
+
The serve journal keeps only the entries matching a keyword list
|
|
568
|
+
(failed, error, done, approval…). A run killed by a message that uses
|
|
569
|
+
none of those words — "No agent provides capability workspace.restore."
|
|
570
|
+
— was therefore filtered out as unimportant, and the panel showed "No
|
|
571
|
+
essential run event yet" over a run that had just died with its reason
|
|
572
|
+
already in hand. What ends a run is essential by construction; the
|
|
573
|
+
prefix states that instead of hoping the wording says so.
|
|
574
|
+
*/
|
|
575
|
+
state.logs.push(`Run failed: ${String(event.payload?.message ?? 'Agent run failed.')}`);
|
|
565
576
|
// A dead run must not leave "pending" plan steps and spinning
|
|
566
577
|
// activities in the persisted projection: they reappeared as ghosts
|
|
567
578
|
// at every relaunch ("des trucs dans le plan qui n'existent pas") and
|
|
@@ -675,3 +675,24 @@ test('a stream resumed after a discard carries only the final text', () => {
|
|
|
675
675
|
assert.equal(projection.conversation[1].content, '12 pages.');
|
|
676
676
|
assert.equal(projection.conversation[1].streaming, undefined, 'le message doit être figé');
|
|
677
677
|
});
|
|
678
|
+
|
|
679
|
+
test('run_error names the failure so the essential journal cannot filter it out', () => {
|
|
680
|
+
// La liste de mots-clés du journal serve (failed, error, done, approval…)
|
|
681
|
+
// rejetait un message qui n'en contient aucun — « No agent provides
|
|
682
|
+
// capability workspace.restore. » — et le panneau affichait « No essential
|
|
683
|
+
// run event yet » au-dessus d'un run mort avec sa raison déjà connue.
|
|
684
|
+
const state = reduceAgentEvents([
|
|
685
|
+
createAgentEvent('run_error', {
|
|
686
|
+
origin: 'runtime',
|
|
687
|
+
runId: 'run-1',
|
|
688
|
+
payload: { message: 'No agent provides capability workspace.restore.' },
|
|
689
|
+
}),
|
|
690
|
+
]);
|
|
691
|
+
|
|
692
|
+
assert.equal(state.status, 'error');
|
|
693
|
+
const line = state.logs.at(-1);
|
|
694
|
+
assert.match(line, /^Run failed: /);
|
|
695
|
+
assert.match(line, /workspace\.restore/);
|
|
696
|
+
// Le mot qui rend l'entrée « essentielle » pour le journal serve.
|
|
697
|
+
assert.match(line, /failed/i);
|
|
698
|
+
});
|
package/src/core/buildInfo.json
CHANGED
|
@@ -27,6 +27,24 @@ test('workspace production agent enables restore by default', async () => {
|
|
|
27
27
|
assert.match(String(allowed), /(?:^|,)restore(?:,|})/);
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
+
test('every shipped default allows the taxonomy step', async () => {
|
|
31
|
+
/*
|
|
32
|
+
L'absence de `taxonomy` est SILENCIEUSE.
|
|
33
|
+
|
|
34
|
+
`agent_plan` retire la tâche taxonomique du fragment d'ingestion quand
|
|
35
|
+
l'étape n'est pas autorisée (`"taxonomy" in _ALLOWED_STEPS`), sans erreur ni
|
|
36
|
+
avertissement. Un déploiement par compose ingérait donc sans jamais publier
|
|
37
|
+
de taxonomie, et la carte restait périmée — exactement le défaut que le Lot 4
|
|
38
|
+
avait corrigé côté moteur. Le défaut de Python porte `taxonomy` ; un compose
|
|
39
|
+
POSITIONNE toujours la variable, donc ce défaut ne s'applique jamais là.
|
|
40
|
+
*/
|
|
41
|
+
const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
|
|
42
|
+
const compose = YAML.parse(raw);
|
|
43
|
+
const allowed = compose.services['production-mcp'].environment
|
|
44
|
+
.find((entry) => String(entry).startsWith('PRODUCTION_ALLOWED_STEPS='));
|
|
45
|
+
assert.match(String(allowed), /(?:^|,)taxonomy(?:,|})/);
|
|
46
|
+
});
|
|
47
|
+
|
|
30
48
|
test('shipped compose files never carry a build context', async () => {
|
|
31
49
|
// Ces deux fichiers partent dans le paquet npm, où les dépôts frères
|
|
32
50
|
// (`../agent-external/…`) n'existent pas : un `build:` y rend toute commande
|
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.15.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.15.50';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
2
|
+
import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
|
|
3
|
+
import { normalizeRuntimeLog } from '../core/runtimeLog.js';
|
|
4
|
+
import { assertContract } from '../contracts/schemas.js';
|
|
5
|
+
|
|
6
|
+
const AVAILABLE = 'available';
|
|
7
|
+
const UNAVAILABLE = 'unavailable';
|
|
8
|
+
/**
|
|
9
|
+
* Santé d'un agent restauré depuis le journal, tant qu'aucun `agent_describe`
|
|
10
|
+
* n'a réussi dans le processus courant.
|
|
11
|
+
*
|
|
12
|
+
* Ni `available` ni `unavailable` : on ne SAIT pas. Le distinguer de
|
|
13
|
+
* `unavailable` a une conséquence pratique — un agent inconnu redevient
|
|
14
|
+
* disponible en silence dès le premier scan réussi, là où un agent déclaré
|
|
15
|
+
* indisponible mériterait d'être signalé comme tel à l'utilisateur.
|
|
16
|
+
*/
|
|
17
|
+
const UNKNOWN = 'unknown';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Un agent persisté n'est pas un agent joignable.
|
|
21
|
+
*
|
|
22
|
+
* Au redémarrage, `hydrateSession` rejoue le journal d'événements et
|
|
23
|
+
* reconstruit les agents avec la santé qu'ils avaient AU MOMENT où l'événement
|
|
24
|
+
* a été écrit. `cme-main` réapparaissait donc `available` alors que son
|
|
25
|
+
* endpoint n'existe plus, était retenu comme fournisseur, et la tâche partait
|
|
26
|
+
* vers un agent absent — la panne observée le 2026-08-04.
|
|
27
|
+
*
|
|
28
|
+
* La persistance dit ce qui a existé, pas ce qui répond maintenant. Seul un
|
|
29
|
+
* `agent_describe` réussi dans ce processus autorise à parler de disponibilité.
|
|
30
|
+
*/
|
|
31
|
+
export function markPersistedAgentsStale(session) {
|
|
32
|
+
if (!session || typeof session !== 'object') return [];
|
|
33
|
+
const stale = (agent) => ({
|
|
34
|
+
...agent,
|
|
35
|
+
health: UNKNOWN,
|
|
36
|
+
stale: true,
|
|
37
|
+
// La santé d'origine est conservée : elle raconte ce qu'on savait avant
|
|
38
|
+
// l'arrêt, ce qui aide à lire un journal, sans jamais servir au routage.
|
|
39
|
+
healthBeforeRestart: agent?.health ?? null,
|
|
40
|
+
});
|
|
41
|
+
/*
|
|
42
|
+
TOUTES les représentations restaurées, `agentRegistrySnapshot` compris.
|
|
43
|
+
|
|
44
|
+
J'avais d'abord épargné le snapshot, au motif qu'il pouvait porter un scan
|
|
45
|
+
vivant. C'était une inversion : à l'hydratation, aucun scan n'a encore eu
|
|
46
|
+
lieu — l'ordre est hydrate → invalidate → discover, et rien ne s'exécute
|
|
47
|
+
entre les deux premiers. Le snapshot vient donc de la même projection
|
|
48
|
+
persistée que `session.agents`. L'épargner laissait `cme-main` routable avec
|
|
49
|
+
son endpoint éteint, ce que la validation à chaud a montré.
|
|
50
|
+
|
|
51
|
+
Le seul scan qui compte est celui qui suivra : `discover()` réécrit le
|
|
52
|
+
snapshot en entier à partir des `agent_describe` réussis.
|
|
53
|
+
*/
|
|
54
|
+
session.agents = (session.agents ?? []).map(stale);
|
|
55
|
+
session.agentRegistrySnapshot = (session.agentRegistrySnapshot ?? []).map(stale);
|
|
56
|
+
return session.agentRegistrySnapshot;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createAgentRegistry({
|
|
60
|
+
callTool = callMcpTool,
|
|
61
|
+
now = () => new Date(),
|
|
62
|
+
} = {}) {
|
|
63
|
+
const agentsByInstance = new Map();
|
|
64
|
+
const instanceByServer = new Map();
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
async discover(session, { signal = null } = {}) {
|
|
68
|
+
const discovered = [];
|
|
69
|
+
const endpoints = Object.entries(session?.mcp ?? {});
|
|
70
|
+
const activeServers = new Set(endpoints.map(([serverName]) => serverName));
|
|
71
|
+
for (const [serverName, endpoint] of endpoints) {
|
|
72
|
+
const agent = await discoverServerAgent(session, serverName, endpoint, { callTool, signal, now });
|
|
73
|
+
discovered.push(registerAgent(session, agent, { agentsByInstance, instanceByServer }));
|
|
74
|
+
}
|
|
75
|
+
for (const [serverName, instanceId] of instanceByServer) {
|
|
76
|
+
if (activeServers.has(serverName)) continue;
|
|
77
|
+
const previous = agentsByInstance.get(instanceId);
|
|
78
|
+
instanceByServer.delete(serverName);
|
|
79
|
+
agentsByInstance.delete(instanceId);
|
|
80
|
+
if (previous) dispatchRegistryEvent(session, 'agent.unregistered', {
|
|
81
|
+
agentInstanceId: instanceId,
|
|
82
|
+
serverName,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
session.agentRegistry = this;
|
|
86
|
+
session.agentRegistrySnapshot = this.snapshot();
|
|
87
|
+
return discovered;
|
|
88
|
+
},
|
|
89
|
+
snapshot() {
|
|
90
|
+
return [...agentsByInstance.values()]
|
|
91
|
+
.map((agent) => cloneAgent(agent))
|
|
92
|
+
.sort((a, b) => a.agentInstanceId.localeCompare(b.agentInstanceId));
|
|
93
|
+
},
|
|
94
|
+
get(agentInstanceId) {
|
|
95
|
+
const agent = agentsByInstance.get(String(agentInstanceId));
|
|
96
|
+
return agent ? cloneAgent(agent) : null;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function discoverServerAgent(session, serverName, endpoint = {}, { callTool, signal, now }) {
|
|
102
|
+
const lastSeenAt = now().toISOString();
|
|
103
|
+
if (endpoint.status !== 'connected') {
|
|
104
|
+
return legacyAgent(serverName, endpoint, { health: UNAVAILABLE, lastSeenAt });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const tool = findAgentDescribeTool(serverName, endpoint.tools ?? []);
|
|
108
|
+
if (!tool) {
|
|
109
|
+
return legacyAgent(serverName, endpoint, { health: AVAILABLE, lastSeenAt });
|
|
110
|
+
}
|
|
111
|
+
const toolName = tool.name;
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const result = await callTool(
|
|
115
|
+
session.mcp,
|
|
116
|
+
serverName,
|
|
117
|
+
toolName,
|
|
118
|
+
describeArguments(tool, session?.workspace),
|
|
119
|
+
signal,
|
|
120
|
+
);
|
|
121
|
+
const description = assertContract('agentDescription', parseToolJsonResult(result));
|
|
122
|
+
return {
|
|
123
|
+
serverName,
|
|
124
|
+
toolName,
|
|
125
|
+
agentInstanceId: description.agentInstanceId,
|
|
126
|
+
description,
|
|
127
|
+
health: description.health?.status ?? AVAILABLE,
|
|
128
|
+
firstSeenAt: lastSeenAt,
|
|
129
|
+
lastSeenAt,
|
|
130
|
+
legacy: false,
|
|
131
|
+
orchestrable: true,
|
|
132
|
+
};
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return legacyAgent(serverName, endpoint, {
|
|
135
|
+
health: UNAVAILABLE,
|
|
136
|
+
lastSeenAt,
|
|
137
|
+
toolName,
|
|
138
|
+
error: error instanceof Error ? error.message : String(error),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function registerAgent(session, agent, { agentsByInstance, instanceByServer }) {
|
|
144
|
+
const previousInstanceId = instanceByServer.get(agent.serverName);
|
|
145
|
+
const previous = previousInstanceId ? agentsByInstance.get(previousInstanceId) : null;
|
|
146
|
+
|
|
147
|
+
/*
|
|
148
|
+
A failed discovery must not erase a known orchestrator agent.
|
|
149
|
+
|
|
150
|
+
When the endpoint is transiently unreachable — the runtime boots before its
|
|
151
|
+
containers (the normal boot order), or a single probe times out —
|
|
152
|
+
`discoverServerAgent` falls back to a legacy agent with no capabilities. The
|
|
153
|
+
old code replaced the orchestrator agent with that fallback, so every
|
|
154
|
+
capability silently vanished from the registry and did not come back until a
|
|
155
|
+
LATER successful discovery. Keep the orchestrator agent and only refresh its
|
|
156
|
+
probe timestamp: its capabilities are still real, only the endpoint is down.
|
|
157
|
+
*/
|
|
158
|
+
if (agent.legacy && previous && !previous.legacy) {
|
|
159
|
+
agentsByInstance.set(previous.agentInstanceId, { ...previous, lastSeenAt: agent.lastSeenAt });
|
|
160
|
+
/*
|
|
161
|
+
Preserving is right; preserving in silence is what caused the hunt.
|
|
162
|
+
|
|
163
|
+
Every defect this registry produced was invisible: capabilities vanished
|
|
164
|
+
without an event, and the resolver could only report the consequence ("no
|
|
165
|
+
agent provides X") long afterwards. Keeping the agent fixes the loss, not
|
|
166
|
+
the blindness — a probe that failed is a fact worth stating, once, where
|
|
167
|
+
the panels and the shell already read.
|
|
168
|
+
|
|
169
|
+
Deliberately NOT a health change: the endpoint is down but the agent stays
|
|
170
|
+
usable by design here, and moving `health` would make `capabilityResolver`
|
|
171
|
+
refuse it — trading a silent loss for a silent refusal.
|
|
172
|
+
*/
|
|
173
|
+
dispatchRuntimeLog(session, `agent-registry: ${agent.serverName} did not answer agent_describe`
|
|
174
|
+
+ `${agent.error ? ` (${agent.error})` : ''}; keeping its known capabilities`
|
|
175
|
+
+ ` (${(previous.description?.capabilities ?? []).map((capability) => capability.id).join(', ') || 'none'}).`);
|
|
176
|
+
return cloneAgent(previous);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const firstSeenAt = previous?.firstSeenAt ?? agent.firstSeenAt ?? agent.lastSeenAt;
|
|
180
|
+
const next = {
|
|
181
|
+
...agent,
|
|
182
|
+
firstSeenAt,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
if (previous && previous.agentInstanceId !== next.agentInstanceId) {
|
|
186
|
+
agentsByInstance.delete(previous.agentInstanceId);
|
|
187
|
+
}
|
|
188
|
+
agentsByInstance.set(next.agentInstanceId, next);
|
|
189
|
+
instanceByServer.set(next.serverName, next.agentInstanceId);
|
|
190
|
+
|
|
191
|
+
if (!previous || previous.agentInstanceId !== next.agentInstanceId) {
|
|
192
|
+
dispatchRegistryEvent(session, 'agent.registered', { agent: next });
|
|
193
|
+
} else if (previous.health !== next.health) {
|
|
194
|
+
dispatchRegistryEvent(session, 'agent.health_changed', {
|
|
195
|
+
agent: next,
|
|
196
|
+
agentInstanceId: next.agentInstanceId,
|
|
197
|
+
previousHealth: previous.health,
|
|
198
|
+
health: next.health,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return cloneAgent(next);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Runtime log line, emitted without importing the supervisor.
|
|
206
|
+
*
|
|
207
|
+
* `emitRuntimeLog` lives in `runtime/supervisor.js`, which already imports THIS
|
|
208
|
+
* module: importing it back would close a cycle for one log line. The event
|
|
209
|
+
* shape is the contract, not the helper, so we build it from the same
|
|
210
|
+
* normalizer the supervisor uses.
|
|
211
|
+
*/
|
|
212
|
+
function dispatchRuntimeLog(session, message) {
|
|
213
|
+
if (!session) return;
|
|
214
|
+
const payload = normalizeRuntimeLog(message, { session });
|
|
215
|
+
dispatchAgentEvent(session, createAgentEvent('runtime_log', {
|
|
216
|
+
origin: 'runtime',
|
|
217
|
+
runId: payload.runId ?? null,
|
|
218
|
+
taskId: payload.taskId ?? null,
|
|
219
|
+
workspace: payload.workspaceId ?? null,
|
|
220
|
+
payload,
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function dispatchRegistryEvent(session, type, payload) {
|
|
225
|
+
if (!session) return;
|
|
226
|
+
dispatchAgentEvent(session, createAgentEvent(type, {
|
|
227
|
+
origin: 'agent_registry',
|
|
228
|
+
workspace: session.workspace ?? null,
|
|
229
|
+
payload,
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function findAgentDescribeTool(serverName, tools) {
|
|
234
|
+
const named = tools.filter((tool) => String(tool?.name ?? ''));
|
|
235
|
+
const byName = (predicate) => named.find((tool) => predicate(String(tool.name)));
|
|
236
|
+
return byName((name) => name === 'agent_describe')
|
|
237
|
+
?? byName((name) => name === `${serverName}__agent_describe`)
|
|
238
|
+
?? byName((name) => name.endsWith('__agent_describe'))
|
|
239
|
+
?? null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Parts of a contract are workspace-scoped — typically the closed vocabulary of
|
|
243
|
+
// an argument (the sources declared in THIS workspace). Published as a bare
|
|
244
|
+
// string, such a field is unverifiable and a planner fills it with any noun
|
|
245
|
+
// from the objective, so it is worth telling the agent which workspace we are
|
|
246
|
+
// asking about.
|
|
247
|
+
//
|
|
248
|
+
// But the orchestrator must not assume an agent accepts an argument it never
|
|
249
|
+
// declared: an agent whose agent_describe schema is `additionalProperties:
|
|
250
|
+
// false` REJECTS the call, drops out of the registry, and its capabilities
|
|
251
|
+
// silently vanish — the objective then resolves to whatever agent is left.
|
|
252
|
+
// Send the workspace only to agents whose own schema says they can take it.
|
|
253
|
+
function describeArguments(tool, workspace) {
|
|
254
|
+
if (!workspace) return {};
|
|
255
|
+
const schema = tool?.inputSchema;
|
|
256
|
+
if (!schema || typeof schema !== 'object') return {};
|
|
257
|
+
const declaresWorkspace = Object.hasOwn(schema.properties ?? {}, 'workspace');
|
|
258
|
+
const acceptsExtra = schema.additionalProperties !== false;
|
|
259
|
+
return declaresWorkspace || acceptsExtra ? { workspace: String(workspace) } : {};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function parseToolJsonResult(result) {
|
|
263
|
+
if (result && typeof result === 'object' && !Array.isArray(result) && !Array.isArray(result.content)) {
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
266
|
+
const text = formatMcpToolResult(result);
|
|
267
|
+
return JSON.parse(text);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function legacyAgent(serverName, endpoint = {}, { health, lastSeenAt, toolName = null, error = null }) {
|
|
271
|
+
const displayName = endpoint.displayName ?? serverName;
|
|
272
|
+
const description = {
|
|
273
|
+
contractVersion: 'legacy',
|
|
274
|
+
agentType: serverName,
|
|
275
|
+
agentInstanceId: `${serverName}-legacy`,
|
|
276
|
+
displayName,
|
|
277
|
+
capabilities: [],
|
|
278
|
+
orchestration: {
|
|
279
|
+
canPlan: false,
|
|
280
|
+
canExpandPlan: false,
|
|
281
|
+
canExecute: false,
|
|
282
|
+
canCancel: false,
|
|
283
|
+
canResume: false,
|
|
284
|
+
supportsIdempotency: false,
|
|
285
|
+
supportsParallelWorkers: false,
|
|
286
|
+
},
|
|
287
|
+
limits: {
|
|
288
|
+
recommendedConcurrency: 0,
|
|
289
|
+
maxConcurrency: 0,
|
|
290
|
+
},
|
|
291
|
+
health: { status: health },
|
|
292
|
+
};
|
|
293
|
+
return {
|
|
294
|
+
serverName,
|
|
295
|
+
toolName,
|
|
296
|
+
agentInstanceId: description.agentInstanceId,
|
|
297
|
+
description,
|
|
298
|
+
health,
|
|
299
|
+
firstSeenAt: lastSeenAt,
|
|
300
|
+
lastSeenAt,
|
|
301
|
+
legacy: true,
|
|
302
|
+
orchestrable: false,
|
|
303
|
+
error,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function cloneAgent(agent) {
|
|
308
|
+
return {
|
|
309
|
+
...agent,
|
|
310
|
+
description: cloneJson(agent.description),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function cloneJson(value) {
|
|
315
|
+
return value == null ? value : JSON.parse(JSON.stringify(value));
|
|
316
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
2
2
|
import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
|
|
3
|
+
import { normalizeRuntimeLog } from '../core/runtimeLog.js';
|
|
3
4
|
import { assertContract } from '../contracts/schemas.js';
|
|
4
5
|
|
|
5
6
|
const AVAILABLE = 'available';
|
|
@@ -142,6 +143,39 @@ async function discoverServerAgent(session, serverName, endpoint = {}, { callToo
|
|
|
142
143
|
function registerAgent(session, agent, { agentsByInstance, instanceByServer }) {
|
|
143
144
|
const previousInstanceId = instanceByServer.get(agent.serverName);
|
|
144
145
|
const previous = previousInstanceId ? agentsByInstance.get(previousInstanceId) : null;
|
|
146
|
+
|
|
147
|
+
/*
|
|
148
|
+
A failed discovery must not erase a known orchestrator agent.
|
|
149
|
+
|
|
150
|
+
When the endpoint is transiently unreachable — the runtime boots before its
|
|
151
|
+
containers (the normal boot order), or a single probe times out —
|
|
152
|
+
`discoverServerAgent` falls back to a legacy agent with no capabilities. The
|
|
153
|
+
old code replaced the orchestrator agent with that fallback, so every
|
|
154
|
+
capability silently vanished from the registry and did not come back until a
|
|
155
|
+
LATER successful discovery. Keep the orchestrator agent and only refresh its
|
|
156
|
+
probe timestamp: its capabilities are still real, only the endpoint is down.
|
|
157
|
+
*/
|
|
158
|
+
if (agent.legacy && previous && !previous.legacy) {
|
|
159
|
+
agentsByInstance.set(previous.agentInstanceId, { ...previous, lastSeenAt: agent.lastSeenAt });
|
|
160
|
+
/*
|
|
161
|
+
Preserving is right; preserving in silence is what caused the hunt.
|
|
162
|
+
|
|
163
|
+
Every defect this registry produced was invisible: capabilities vanished
|
|
164
|
+
without an event, and the resolver could only report the consequence ("no
|
|
165
|
+
agent provides X") long afterwards. Keeping the agent fixes the loss, not
|
|
166
|
+
the blindness — a probe that failed is a fact worth stating, once, where
|
|
167
|
+
the panels and the shell already read.
|
|
168
|
+
|
|
169
|
+
Deliberately NOT a health change: the endpoint is down but the agent stays
|
|
170
|
+
usable by design here, and moving `health` would make `capabilityResolver`
|
|
171
|
+
refuse it — trading a silent loss for a silent refusal.
|
|
172
|
+
*/
|
|
173
|
+
dispatchRuntimeLog(session, `agent-registry: ${agent.serverName} did not answer agent_describe`
|
|
174
|
+
+ `${agent.error ? ` (${agent.error})` : ''}; keeping its known capabilities`
|
|
175
|
+
+ ` (${(previous.description?.capabilities ?? []).map((capability) => capability.id).join(', ') || 'none'}).`);
|
|
176
|
+
return cloneAgent(previous);
|
|
177
|
+
}
|
|
178
|
+
|
|
145
179
|
const firstSeenAt = previous?.firstSeenAt ?? agent.firstSeenAt ?? agent.lastSeenAt;
|
|
146
180
|
const next = {
|
|
147
181
|
...agent,
|
|
@@ -167,6 +201,26 @@ function registerAgent(session, agent, { agentsByInstance, instanceByServer }) {
|
|
|
167
201
|
return cloneAgent(next);
|
|
168
202
|
}
|
|
169
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Runtime log line, emitted without importing the supervisor.
|
|
206
|
+
*
|
|
207
|
+
* `emitRuntimeLog` lives in `runtime/supervisor.js`, which already imports THIS
|
|
208
|
+
* module: importing it back would close a cycle for one log line. The event
|
|
209
|
+
* shape is the contract, not the helper, so we build it from the same
|
|
210
|
+
* normalizer the supervisor uses.
|
|
211
|
+
*/
|
|
212
|
+
function dispatchRuntimeLog(session, message) {
|
|
213
|
+
if (!session) return;
|
|
214
|
+
const payload = normalizeRuntimeLog(message, { session });
|
|
215
|
+
dispatchAgentEvent(session, createAgentEvent('runtime_log', {
|
|
216
|
+
origin: 'runtime',
|
|
217
|
+
runId: payload.runId ?? null,
|
|
218
|
+
taskId: payload.taskId ?? null,
|
|
219
|
+
workspace: payload.workspaceId ?? null,
|
|
220
|
+
payload,
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
|
|
170
224
|
function dispatchRegistryEvent(session, type, payload) {
|
|
171
225
|
if (!session) return;
|
|
172
226
|
dispatchAgentEvent(session, createAgentEvent(type, {
|
|
@@ -134,6 +134,81 @@ test('agentRegistry marks unavailable boot agents and emits health changes on re
|
|
|
134
134
|
assert.equal(registry.snapshot()[0].health, 'available');
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
+
test('a failed re-discovery keeps the orchestrator agent, never erases its capabilities', async () => {
|
|
138
|
+
/*
|
|
139
|
+
Boot order: the runtime starts before its containers. The first scan sees the
|
|
140
|
+
production endpoint "unavailable" and would register a legacy placeholder —
|
|
141
|
+
and the old code replaced the orchestrator agent with it, silently dropping
|
|
142
|
+
every capability until a LATER successful discovery. The agent must survive a
|
|
143
|
+
transient probe failure, because its capabilities did not change.
|
|
144
|
+
*/
|
|
145
|
+
const events = [];
|
|
146
|
+
const session = {
|
|
147
|
+
workspace: 'acpi',
|
|
148
|
+
mcp: {
|
|
149
|
+
production: { status: 'connected', tools: [{ name: 'agent_describe' }] },
|
|
150
|
+
},
|
|
151
|
+
_onAgentEvent: (event) => events.push(event),
|
|
152
|
+
};
|
|
153
|
+
const registry = createAgentRegistry({
|
|
154
|
+
callTool: async () => ({ content: [{ type: 'text', text: JSON.stringify(description()) }] }),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await registry.discover(session);
|
|
158
|
+
assert.equal(registry.snapshot()[0].agentInstanceId, 'production-main');
|
|
159
|
+
|
|
160
|
+
// The endpoint goes down: discovery now falls back to a legacy placeholder.
|
|
161
|
+
session.mcp.production.status = 'unavailable';
|
|
162
|
+
await registry.discover(session);
|
|
163
|
+
|
|
164
|
+
const [agent] = registry.snapshot();
|
|
165
|
+
assert.equal(agent.agentInstanceId, 'production-main');
|
|
166
|
+
assert.equal(agent.legacy, false);
|
|
167
|
+
assert.equal(agent.description.capabilities.length, 1);
|
|
168
|
+
// And it is still routable: the capability was not erased.
|
|
169
|
+
const capability = createCapabilityRegistry({ agents: registry.snapshot() });
|
|
170
|
+
assert.equal(capability.providersFor('knowledge.update').length, 1);
|
|
171
|
+
|
|
172
|
+
/*
|
|
173
|
+
Preserving must not be silent.
|
|
174
|
+
|
|
175
|
+
Every defect this registry produced was invisible, and that is what turned a
|
|
176
|
+
boot-order race into a debugging session: the capability vanished with no
|
|
177
|
+
event, and the only report came much later, from the resolver, as "no agent
|
|
178
|
+
provides X". A probe that failed is a fact, and it belongs where the shell
|
|
179
|
+
and the panels already read.
|
|
180
|
+
*/
|
|
181
|
+
const kept = events.find((event) => event.type === 'runtime_log'
|
|
182
|
+
&& String(event.payload?.message ?? '').includes('agent-registry:'));
|
|
183
|
+
assert.ok(kept, 'a preserved agent must leave a runtime log');
|
|
184
|
+
assert.match(String(kept.payload.message), /did not answer agent_describe/);
|
|
185
|
+
assert.match(String(kept.payload.message), /knowledge\.update/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('a failed re-discovery keeps a degraded orchestrator agent too', async () => {
|
|
189
|
+
// Same protection, but when the agent was discovered healthy then the probe
|
|
190
|
+
// throws (endpoint "connected" but agent_describe fails mid-flight).
|
|
191
|
+
const session = {
|
|
192
|
+
mcp: { production: { status: 'connected', tools: [{ name: 'agent_describe' }] } },
|
|
193
|
+
};
|
|
194
|
+
let fail = false;
|
|
195
|
+
const registry = createAgentRegistry({
|
|
196
|
+
callTool: async () => {
|
|
197
|
+
if (fail) throw new Error('agent_describe timeout');
|
|
198
|
+
return { content: [{ type: 'text', text: JSON.stringify(description()) }] };
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await registry.discover(session);
|
|
203
|
+
fail = true;
|
|
204
|
+
await registry.discover(session);
|
|
205
|
+
|
|
206
|
+
const [agent] = registry.snapshot();
|
|
207
|
+
assert.equal(agent.agentInstanceId, 'production-main');
|
|
208
|
+
assert.equal(agent.legacy, false);
|
|
209
|
+
assert.equal(agent.description.capabilities.length, 1);
|
|
210
|
+
});
|
|
211
|
+
|
|
137
212
|
test('discovery sends the workspace only to agents whose schema declares it', async () => {
|
|
138
213
|
const seen = {};
|
|
139
214
|
const registry = createAgentRegistry({
|
package/src/runtime/client.js
CHANGED
|
@@ -261,6 +261,7 @@ export async function postRuntimeApprove({
|
|
|
261
261
|
scope = null,
|
|
262
262
|
planRevision = null,
|
|
263
263
|
approvalClasses = null,
|
|
264
|
+
caller = null,
|
|
264
265
|
} = {}) {
|
|
265
266
|
const endpoint = runtimeEndpoint(url, '/approve', workspace);
|
|
266
267
|
const parsed = new URL(endpoint);
|
|
@@ -278,6 +279,7 @@ export async function postRuntimeApprove({
|
|
|
278
279
|
scope,
|
|
279
280
|
planRevision,
|
|
280
281
|
approvalClasses,
|
|
282
|
+
caller,
|
|
281
283
|
}),
|
|
282
284
|
});
|
|
283
285
|
if (!response.ok) throw new Error(`Runtime approve failed: HTTP ${response.status}`);
|
|
@@ -94,7 +94,7 @@ export function integratePreparedDelegation({
|
|
|
94
94
|
session,
|
|
95
95
|
approval.approved
|
|
96
96
|
? `approval: run ${runId} auto-approved (autoApprove opt-in)`
|
|
97
|
-
: `approval: run ${runId} awaiting explicit approval before mutations (/approve
|
|
97
|
+
: `approval: run ${runId} awaiting explicit approval before mutations (/approve)`,
|
|
98
98
|
);
|
|
99
99
|
return { integrated, approval };
|
|
100
100
|
}
|
package/src/runtime/runner.js
CHANGED
|
@@ -407,8 +407,8 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
407
407
|
sanitizeSessionPlanForExecution(session, runId);
|
|
408
408
|
ensurePlanProjection(session, runId);
|
|
409
409
|
emitRuntimeLog(session, `scheduler: parallel plan enabled (concurrency ${limit}; agent=${concurrencyDetail.agentLimit ?? 'n/a'}, ceiling=${concurrencyDetail.ceiling ?? 'none'}${concurrencyDetail.cappedByCeiling ? ' → capped by manager ceiling' : ''})`);
|
|
410
|
-
// Interactive approvals do NOT expire: the user has /approve,
|
|
411
|
-
//
|
|
410
|
+
// Interactive approvals do NOT expire: the user has /approve, /cancel and
|
|
411
|
+
// /run kill — an arbitrary timer only created mystery
|
|
412
412
|
// failures. A deadline exists only when explicitly configured (headless
|
|
413
413
|
// runs, CI) via the session or the env escape hatch.
|
|
414
414
|
const configuredApprovalWait = Number(session._approvalTimeoutMs) > 0
|
|
@@ -572,7 +572,7 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
572
572
|
`⏸ Approbation requise avant exécution : ${newlyRequested.length} tâche(s) mutante(s) en attente.`,
|
|
573
573
|
...newlyRequested.slice(0, 5).map((step) => ` - ${step.description ?? step.id}`),
|
|
574
574
|
newlyRequested.length > 5 ? ` … et ${newlyRequested.length - 5} autre(s).` : null,
|
|
575
|
-
'
|
|
575
|
+
'Tape /approve (ou clique sur « Approuver ») pour lancer, « annule » pour abandonner.',
|
|
576
576
|
].filter(Boolean).join('\n'),
|
|
577
577
|
},
|
|
578
578
|
}));
|
package/src/runtime/server.js
CHANGED
|
@@ -642,6 +642,7 @@ export function startRuntimeServer({
|
|
|
642
642
|
groupId: url.searchParams.get('groupId') ?? body.groupId ?? null,
|
|
643
643
|
planRevision: readOptionalNumber(url.searchParams.get('planRevision') ?? body.planRevision),
|
|
644
644
|
approvalClasses: readOptionalList(body.approvalClasses ?? body.approvalClass ?? url.searchParams.get('approvalClass')),
|
|
645
|
+
caller: body.caller ?? url.searchParams.get('caller') ?? null,
|
|
645
646
|
});
|
|
646
647
|
sendJson(response, result?.approved ? 202 : 404, result ?? { approved: false });
|
|
647
648
|
return;
|
|
@@ -805,7 +806,10 @@ export function startRuntimeServer({
|
|
|
805
806
|
runPromise
|
|
806
807
|
.catch((err) => {
|
|
807
808
|
rejectReady?.(err);
|
|
808
|
-
|
|
809
|
+
// The runId travels with the failure: without it `finishControlByRun`
|
|
810
|
+
// had nothing to match, so a queued restore stayed "pending" forever
|
|
811
|
+
// while the run that carried it was already dead.
|
|
812
|
+
context.session?._onRuntimeError?.(err, runId);
|
|
809
813
|
})
|
|
810
814
|
.finally(() => {
|
|
811
815
|
context.running = false;
|
|
@@ -1371,9 +1375,6 @@ function classifyControlMessage(input, status, forcedIntent = null) {
|
|
|
1371
1375
|
if (explicit) {
|
|
1372
1376
|
return { kind: explicit, confidence: 1, reason: 'explicit_intent' };
|
|
1373
1377
|
}
|
|
1374
|
-
if (/\b(valide tout|approve all|approve|approuve|valid[eé]|ok pour tout|go pour tout)\b/i.test(lower)) {
|
|
1375
|
-
return { kind: 'approve', confidence: 0.86, reason: 'approval_request' };
|
|
1376
|
-
}
|
|
1377
1378
|
if (/\b(cancel|annule|stop|arr[eê]te|interromps|abort)\b/i.test(lower)) {
|
|
1378
1379
|
return { kind: 'cancel', confidence: 0.86, reason: 'cancel_request' };
|
|
1379
1380
|
}
|
|
@@ -1082,6 +1082,7 @@ test('runtime server exposes approval endpoint', async (t) => {
|
|
|
1082
1082
|
groupId: null,
|
|
1083
1083
|
planRevision: null,
|
|
1084
1084
|
approvalClasses: [],
|
|
1085
|
+
caller: null,
|
|
1085
1086
|
});
|
|
1086
1087
|
assert.deepEqual(await response.json(), { approved: true, runId: 'run-1', itemId: 'item-1' });
|
|
1087
1088
|
} finally {
|
|
@@ -1307,11 +1308,21 @@ test('runtime server control message handles approve and cancel intents during a
|
|
|
1307
1308
|
const approveResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
|
|
1308
1309
|
method: 'POST',
|
|
1309
1310
|
headers: { 'Content-Type': 'application/json' },
|
|
1310
|
-
body: JSON.stringify({ action: 'message', input: 'valide tout' }),
|
|
1311
|
+
body: JSON.stringify({ action: 'message', input: 'valide tout', intent: 'approve' }),
|
|
1311
1312
|
});
|
|
1312
1313
|
assert.equal(approveResponse.status, 200);
|
|
1313
1314
|
assert.equal((await approveResponse.json()).kind, 'approve');
|
|
1314
1315
|
|
|
1316
|
+
// Free-text approval phrasing is NOT honored anymore: approval is an
|
|
1317
|
+
// explicit /approve (or the approval button), never a keyword match.
|
|
1318
|
+
const freeTextResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
|
|
1319
|
+
method: 'POST',
|
|
1320
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1321
|
+
body: JSON.stringify({ action: 'message', input: 'valide tout' }),
|
|
1322
|
+
});
|
|
1323
|
+
assert.equal(freeTextResponse.status, 200);
|
|
1324
|
+
assert.notEqual((await freeTextResponse.json()).kind, 'approve');
|
|
1325
|
+
|
|
1315
1326
|
const cancelResponse = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
|
|
1316
1327
|
method: 'POST',
|
|
1317
1328
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -13,6 +13,7 @@ export function startActivitySupervisor(session, {
|
|
|
13
13
|
agentRegistryIntervalMs = registryIntervalFromEnv(),
|
|
14
14
|
agentRegistry = null,
|
|
15
15
|
callTool = callMcpTool,
|
|
16
|
+
refreshMcp = null,
|
|
16
17
|
} = {}) {
|
|
17
18
|
const pollBusy = new Set();
|
|
18
19
|
let stopped = false;
|
|
@@ -32,8 +33,24 @@ export function startActivitySupervisor(session, {
|
|
|
32
33
|
}
|
|
33
34
|
}, queueIntervalMs);
|
|
34
35
|
|
|
36
|
+
/*
|
|
37
|
+
The re-scan must probe the endpoints again, not trust a stale status.
|
|
38
|
+
|
|
39
|
+
The first discovery ran while the containers were still coming up, so the
|
|
40
|
+
production endpoint was marked "not connected" and its agent fell back to a
|
|
41
|
+
legacy placeholder. Re-scanning against that cached status kept the fallback
|
|
42
|
+
forever. `refreshMcp` re-resolves the endpoint states (and their tools)
|
|
43
|
+
before each discovery, so a container that came up is actually discovered.
|
|
44
|
+
*/
|
|
35
45
|
const agentRegistryTimer = agentRegistryIntervalMs > 0
|
|
36
|
-
? setInterval(() => {
|
|
46
|
+
? setInterval(async () => {
|
|
47
|
+
if (refreshMcp) {
|
|
48
|
+
try {
|
|
49
|
+
await refreshMcp(session);
|
|
50
|
+
} catch {
|
|
51
|
+
/* the discovery below still runs on the cached status */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
37
54
|
void discoverAgentsOnce(session, { registry, signal: runSignal });
|
|
38
55
|
}, agentRegistryIntervalMs)
|
|
39
56
|
: null;
|
|
@@ -246,6 +246,49 @@ test('startActivitySupervisor periodically re-scans the agent registry', async (
|
|
|
246
246
|
}
|
|
247
247
|
});
|
|
248
248
|
|
|
249
|
+
test('startActivitySupervisor refreshes MCP before each periodic re-scan', async () => {
|
|
250
|
+
/*
|
|
251
|
+
Without the refresh, the re-scan re-reads a cached "not connected" endpoint
|
|
252
|
+
status and keeps a legacy placeholder forever, so an agent whose container
|
|
253
|
+
came up after boot is never discovered. The refresh probes the endpoints
|
|
254
|
+
again before the scan.
|
|
255
|
+
*/
|
|
256
|
+
const session = {
|
|
257
|
+
mcp: {},
|
|
258
|
+
activities: {},
|
|
259
|
+
headlessPlan: null,
|
|
260
|
+
jobQueue: [],
|
|
261
|
+
};
|
|
262
|
+
let refreshes = 0;
|
|
263
|
+
let discoveries = 0;
|
|
264
|
+
const registry = {
|
|
265
|
+
async discover() {
|
|
266
|
+
discoveries += 1;
|
|
267
|
+
return [];
|
|
268
|
+
},
|
|
269
|
+
snapshot() {
|
|
270
|
+
return [];
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const supervisor = startActivitySupervisor(session, {
|
|
275
|
+
intervalMs: 1000,
|
|
276
|
+
queueIntervalMs: 1000,
|
|
277
|
+
agentRegistryIntervalMs: 10,
|
|
278
|
+
agentRegistry: registry,
|
|
279
|
+
refreshMcp: async () => {
|
|
280
|
+
refreshes += 1;
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
await waitFor(() => refreshes >= 2);
|
|
286
|
+
assert.ok(discoveries >= 2);
|
|
287
|
+
} finally {
|
|
288
|
+
supervisor.stop();
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
249
292
|
test('discoverAgentsOnce uses the session registry and returns discovered agents', async () => {
|
|
250
293
|
const expected = [{ agentInstanceId: 'a' }];
|
|
251
294
|
const registry = {
|
package/src/shell/LeftPane.tsx
CHANGED
|
@@ -822,14 +822,17 @@ export function LeftPane(props: {
|
|
|
822
822
|
const showWelcome = () => props.showWelcome && props.messages.length === 0;
|
|
823
823
|
return (
|
|
824
824
|
<box width={props.width} height="100%" flexDirection="column" padding={1} overflow="hidden">
|
|
825
|
-
|
|
825
|
+
{/*
|
|
826
|
+
The header keeps what changes the MEANING of what is typed — the mode,
|
|
827
|
+
and the transient hint. Workspace, version and connection states moved
|
|
828
|
+
to the foot of the pane: they are an ambient state one glances at, not
|
|
829
|
+
something read before every message, and at the top they pushed the
|
|
830
|
+
conversation down by a line for standing information.
|
|
831
|
+
*/}
|
|
832
|
+
<box height={2} flexDirection="column">
|
|
826
833
|
<box height={1} flexDirection="row" backgroundColor={modeColor()} paddingX={1}>
|
|
827
834
|
<text fg="#0B1020">{modeLabel()}</text>
|
|
828
835
|
</box>
|
|
829
|
-
<box height={1} flexDirection="row">
|
|
830
|
-
<text fg="#D6DEE8">{props.title}</text>
|
|
831
|
-
<text fg="#7F8C8D"> {props.statusLine}</text>
|
|
832
|
-
</box>
|
|
833
836
|
<box height={1} flexDirection="row">
|
|
834
837
|
{props.hintLine ? <text fg="#FBBF24">[ {props.hintLine} ]</text> : null}
|
|
835
838
|
</box>
|
|
@@ -860,6 +863,15 @@ export function LeftPane(props: {
|
|
|
860
863
|
onSubmit={props.submit}
|
|
861
864
|
onHeightChange={props.onInputHeightChange}
|
|
862
865
|
/>
|
|
866
|
+
{/*
|
|
867
|
+
Status foot. One line, as in the header before it, so the row budget of
|
|
868
|
+
`conversationRows` (height - 5 - input) is unchanged: the line moved,
|
|
869
|
+
it was not added.
|
|
870
|
+
*/}
|
|
871
|
+
<box height={1} flexDirection="row">
|
|
872
|
+
<text fg="#D6DEE8">{props.title}</text>
|
|
873
|
+
<text fg="#7F8C8D"> {props.statusLine}</text>
|
|
874
|
+
</box>
|
|
863
875
|
</box>
|
|
864
876
|
);
|
|
865
877
|
}
|
|
@@ -249,7 +249,15 @@ export function StartupScreen(props: {
|
|
|
249
249
|
return `${workspace} / ${profile}`;
|
|
250
250
|
});
|
|
251
251
|
|
|
252
|
-
|
|
252
|
+
/*
|
|
253
|
+
Usable width inside the panel.
|
|
254
|
+
|
|
255
|
+
The panel has no border any more, only `padding={2}`: 4 columns, not 6. The
|
|
256
|
+
two extra columns were the border's, and keeping them would leave the text
|
|
257
|
+
measured for a frame that is no longer drawn — every `fit()` truncating two
|
|
258
|
+
characters early for nothing.
|
|
259
|
+
*/
|
|
260
|
+
const innerWidth = createMemo(() => Math.max(40, panelWidth() - 4));
|
|
253
261
|
const shortcutHint = createMemo(() => {
|
|
254
262
|
const count = currentLength();
|
|
255
263
|
const quick = count === 1 ? '1 quick select' : `1-${count} quick select`;
|
|
@@ -267,9 +275,6 @@ export function StartupScreen(props: {
|
|
|
267
275
|
top={top()}
|
|
268
276
|
width={panelWidth()}
|
|
269
277
|
height={panelHeight()}
|
|
270
|
-
border
|
|
271
|
-
borderStyle="rounded"
|
|
272
|
-
borderColor="#8BD5CA"
|
|
273
278
|
backgroundColor="#111318"
|
|
274
279
|
padding={2}
|
|
275
280
|
flexDirection="column"
|
package/src/shell/repl.js
CHANGED
|
@@ -2115,6 +2115,7 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
|
|
|
2115
2115
|
url: runtime.url,
|
|
2116
2116
|
workspace: session.workspace ?? null,
|
|
2117
2117
|
...(kind === 'item' ? { itemId: id } : kind === 'approval' ? { approvalId: id } : { runId: id }),
|
|
2118
|
+
caller: 'shell-/approve',
|
|
2118
2119
|
});
|
|
2119
2120
|
conversationMessages(session).push({ role: 'command', content: `Approval ${result.approved ? 'accepted' : 'not found'}: ${id}` });
|
|
2120
2121
|
syncRuntimeState();
|