dsh-ntr 0.1.3 → 0.1.5
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.en.md +28 -5
- package/README.md +28 -5
- package/cordis.patch.yml +32 -0
- package/lib/bridge.js +476 -49
- package/lib/client/acp-ui.js +3 -1
- package/lib/client/panel.js +185 -14
- package/lib/client/session-controls.js +27 -11
- package/lib/client/store.js +55 -1
- package/lib/client.js +392 -43
- package/lib/client.js.map +1 -1
- package/lib/impl.js +318 -14
- package/lib/index.js +31 -1
- package/lib/types/bridge.d.ts +223 -17
- package/lib/types/client/acp-ui.d.ts +6 -0
- package/lib/types/client/panel.d.ts +46 -0
- package/lib/types/client/session-controls.d.ts +2 -0
- package/lib/types/client/store.d.ts +22 -2
- package/lib/types/impl.d.ts +135 -3
- package/lib/types/protocol.d.ts +157 -8
- package/package.json +1 -1
- package/src/bridge.ts +524 -59
- package/src/client/acp-ui.tsx +13 -2
- package/src/client/panel.tsx +328 -11
- package/src/client/session-controls.tsx +29 -11
- package/src/client/store.ts +66 -3
- package/src/impl.ts +373 -17
- package/src/index.ts +30 -2
- package/src/protocol.ts +158 -8
package/lib/bridge.js
CHANGED
|
@@ -12,17 +12,70 @@ import { delimiter, dirname, join } from 'node:path';
|
|
|
12
12
|
import { Writable, Readable } from 'node:stream';
|
|
13
13
|
import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import { client, methods, ndJsonStream, PROTOCOL_VERSION, RequestError, } from '@agentclientprotocol/sdk';
|
|
15
|
-
import { AcpError, EventHub, PermissionLedger, Transcript, acpToolCallDelta, acpToolCallFromUpdate, buildAdapterModelId, childEnv, isAcpToolContinuation, isUnderPath, parseCatalogOptions, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, } from "./impl.js";
|
|
15
|
+
import { AcpError, EventHub, PermissionLedger, Transcript, acpToolCallDelta, acpToolCallFromUpdate, buildAdapterModelId, childEnv, isAcpToolContinuation, isUnderPath, mergeCatalogShape, parseCatalogOptions, usableConfigOptions, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, validateApprovalDeadlines, } from "./impl.js";
|
|
16
16
|
import { DEFAULT_TAG_HALO } from "./protocol.js";
|
|
17
17
|
const STDERR_TAIL_LINES = 40;
|
|
18
18
|
/** Single fs/read_text_file answer cap — agents reading huge files must page via `line`/`limit`. */
|
|
19
19
|
const FS_MAX_READ_BYTES = 2 * 1024 * 1024;
|
|
20
20
|
/** Single fs/write_text_file payload cap. */
|
|
21
21
|
const FS_MAX_WRITE_BYTES = 2 * 1024 * 1024;
|
|
22
|
+
/**
|
|
23
|
+
* The plugin's normalized MCP server shape → the ACP wire shape.
|
|
24
|
+
*
|
|
25
|
+
* THE WIRE SHAPE IS ASYMMETRIC, and getting it wrong is SILENT:
|
|
26
|
+
*
|
|
27
|
+
* - the stdio variant carries NO `type` field — an entry that carries
|
|
28
|
+
* `type:'stdio'` is accepted by the agent and then never connected
|
|
29
|
+
* (measured against codebuddy: the server never appeared, the agent just
|
|
30
|
+
* reported the tool as missing);
|
|
31
|
+
* - http/sse DO carry `type`;
|
|
32
|
+
* - `env` / `headers` are `[{name,value}]` arrays on the wire, while the
|
|
33
|
+
* plugin stores them as records (like `AcpAgentDef.env`).
|
|
34
|
+
*
|
|
35
|
+
* So the conversion lives here, once, and each branch is annotated with the
|
|
36
|
+
* SDK's OWN variant type — a wrong conversion does not compile. (The bare
|
|
37
|
+
* `McpServer` union is too loose to catch it: its excess-property check
|
|
38
|
+
* accepts any member's keys, so a stdio entry carrying `type:'stdio'` would
|
|
39
|
+
* type-check against the http variant and only fail at runtime, inside the
|
|
40
|
+
* agent, as a server that never connects.)
|
|
41
|
+
*/
|
|
42
|
+
function toAcpMcpServers(defs) {
|
|
43
|
+
if (defs === undefined)
|
|
44
|
+
return [];
|
|
45
|
+
return defs.map(def => {
|
|
46
|
+
if (def.transport === 'stdio') {
|
|
47
|
+
// NO `type` here — see the note above.
|
|
48
|
+
const server = {
|
|
49
|
+
name: def.name,
|
|
50
|
+
command: def.command,
|
|
51
|
+
args: def.args ?? [],
|
|
52
|
+
env: Object.entries(def.env ?? {}).map(([name, value]) => ({ name, value })),
|
|
53
|
+
};
|
|
54
|
+
return server;
|
|
55
|
+
}
|
|
56
|
+
if (def.transport === 'http') {
|
|
57
|
+
const server = {
|
|
58
|
+
type: 'http',
|
|
59
|
+
name: def.name,
|
|
60
|
+
url: def.url,
|
|
61
|
+
headers: Object.entries(def.headers ?? {}).map(([name, value]) => ({ name, value })),
|
|
62
|
+
};
|
|
63
|
+
return server;
|
|
64
|
+
}
|
|
65
|
+
const server = {
|
|
66
|
+
type: 'sse',
|
|
67
|
+
name: def.name,
|
|
68
|
+
url: def.url,
|
|
69
|
+
headers: Object.entries(def.headers ?? {}).map(([name, value]) => ({ name, value })),
|
|
70
|
+
};
|
|
71
|
+
return server;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
22
74
|
const DEFAULT_TUNING = {
|
|
23
75
|
startupTimeoutMs: 60_000,
|
|
24
76
|
permissionTimeoutMs: 0,
|
|
25
77
|
permissionFallbackTimeoutMs: 300_000,
|
|
78
|
+
nativeApprovalTimeoutMs: 300_000,
|
|
26
79
|
cancelFallbackMs: 15_000,
|
|
27
80
|
staleCancelRetryDelayMs: 600,
|
|
28
81
|
staleCancelMaxRetries: 2,
|
|
@@ -64,7 +117,18 @@ export class AgentBridge {
|
|
|
64
117
|
ledgerFallbacks = new Map;
|
|
65
118
|
startupTimeoutMs;
|
|
66
119
|
permissionTimeoutMs;
|
|
120
|
+
/**
|
|
121
|
+
* The two approval deadlines are NOT readonly, unlike their neighbors: the
|
|
122
|
+
* settings page rewrites them on a running bridge
|
|
123
|
+
* ({@link AgentBridge.applyApprovalDeadlines}), so a user editing the window
|
|
124
|
+
* does not have to kill every live session to see it take effect. Both are
|
|
125
|
+
* read per ask — `nativeApprovalTimeoutMs` when the native ask is armed,
|
|
126
|
+
* `permissionFallbackTimeoutMs` when the ledger ask's deadline is chosen — so
|
|
127
|
+
* a reassignment reaches every ask from that moment on. An ask already
|
|
128
|
+
* holding an armed timer keeps the window it was started with.
|
|
129
|
+
*/
|
|
67
130
|
permissionFallbackTimeoutMs;
|
|
131
|
+
nativeApprovalTimeoutMs;
|
|
68
132
|
cancelFallbackMs;
|
|
69
133
|
staleCancelRetryDelayMs;
|
|
70
134
|
staleCancelMaxRetries;
|
|
@@ -101,7 +165,13 @@ export class AgentBridge {
|
|
|
101
165
|
constructor(def, hub, transcriptMax,
|
|
102
166
|
/** Called when the connection dies so the manager can settle outstanding prompts. */
|
|
103
167
|
onConnectionLost,
|
|
104
|
-
/**
|
|
168
|
+
/**
|
|
169
|
+
* Observes `configOptions` from any real session so the manager's model
|
|
170
|
+
* catalog stays warm. `shapeOnly` marks a per-session answer (a
|
|
171
|
+
* `session/set_config_option` response): only the option SHAPE belongs in
|
|
172
|
+
* the agent-level catalog, never its `currentValue` — see
|
|
173
|
+
* {@link BridgeManager.absorbCatalogShape}.
|
|
174
|
+
*/
|
|
105
175
|
onCatalog,
|
|
106
176
|
/** Observes `availableCommands` from any real session so the agent's slash menu survives its sessions. */
|
|
107
177
|
onCommands, tuning,
|
|
@@ -127,6 +197,7 @@ export class AgentBridge {
|
|
|
127
197
|
this.startupTimeoutMs = merged.startupTimeoutMs;
|
|
128
198
|
this.permissionTimeoutMs = merged.permissionTimeoutMs;
|
|
129
199
|
this.permissionFallbackTimeoutMs = merged.permissionFallbackTimeoutMs;
|
|
200
|
+
this.nativeApprovalTimeoutMs = merged.nativeApprovalTimeoutMs;
|
|
130
201
|
this.cancelFallbackMs = merged.cancelFallbackMs;
|
|
131
202
|
this.staleCancelRetryDelayMs = merged.staleCancelRetryDelayMs;
|
|
132
203
|
this.staleCancelMaxRetries = merged.staleCancelMaxRetries;
|
|
@@ -134,6 +205,22 @@ export class AgentBridge {
|
|
|
134
205
|
this.defaultIdleTimeoutMs = merged.idleTimeoutMs;
|
|
135
206
|
this.probeIdleTimeoutMs = merged.probeIdleTimeoutMs;
|
|
136
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Adopt a rewritten approval deadline (the settings page's write). Only the
|
|
210
|
+
* two degraded-route windows live here; every other tuning value is fixed at
|
|
211
|
+
* construction, because a live bridge cannot meaningfully adopt a new startup
|
|
212
|
+
* or cancel window mid-flight.
|
|
213
|
+
*
|
|
214
|
+
* Deliberately does NOT re-arm anything: an ask that already holds a timer
|
|
215
|
+
* keeps the window it was started with, and the next ask reads the new value.
|
|
216
|
+
* Re-arming would move the goalposts under a human who is already deciding.
|
|
217
|
+
*/
|
|
218
|
+
applyApprovalDeadlines(patch) {
|
|
219
|
+
if (patch.nativeApprovalTimeoutMs !== undefined)
|
|
220
|
+
this.nativeApprovalTimeoutMs = patch.nativeApprovalTimeoutMs;
|
|
221
|
+
if (patch.permissionFallbackTimeoutMs !== undefined)
|
|
222
|
+
this.permissionFallbackTimeoutMs = patch.permissionFallbackTimeoutMs;
|
|
223
|
+
}
|
|
137
224
|
/**
|
|
138
225
|
* The idle-release window this agent honors for its CHAT process: its own
|
|
139
226
|
* field, else the host default. This is the number the panel prefills and
|
|
@@ -464,7 +551,14 @@ export class AgentBridge {
|
|
|
464
551
|
protocolVersion: PROTOCOL_VERSION,
|
|
465
552
|
// The fs handlers are implemented below behind the same workspace
|
|
466
553
|
// sandbox that gates session cwds; terminal is not implemented.
|
|
467
|
-
|
|
554
|
+
// `session.configOptions.boolean` advertises the toggle form of
|
|
555
|
+
// `configOptions`; agents that gate `type: "boolean"` entries on it
|
|
556
|
+
// would otherwise never publish them.
|
|
557
|
+
clientCapabilities: {
|
|
558
|
+
fs: { readTextFile: true, writeTextFile: true },
|
|
559
|
+
terminal: false,
|
|
560
|
+
session: { configOptions: { boolean: {} } },
|
|
561
|
+
},
|
|
468
562
|
clientInfo: { name: 'dsh-ntr', title: 'DSH NTR', version: '0.1.0' },
|
|
469
563
|
}), 'initialize');
|
|
470
564
|
this.initResult = init;
|
|
@@ -515,8 +609,9 @@ export class AgentBridge {
|
|
|
515
609
|
if (conn === undefined)
|
|
516
610
|
throw new AcpError('agent_unreachable', 'acp: connection is not open');
|
|
517
611
|
const resolved = resolveSessionCwd(this.def, cwd, p => realpathSync(p), extraRoots);
|
|
518
|
-
const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.new, { cwd: resolved, mcpServers:
|
|
519
|
-
|
|
612
|
+
const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.new, { cwd: resolved, mcpServers: toAcpMcpServers(this.def.mcpServers) }), 'session/new');
|
|
613
|
+
// An empty `configOptions` answer is a non-answer — see `usableConfigOptions`.
|
|
614
|
+
if (this.onCatalog !== undefined && usableConfigOptions(result.configOptions)) {
|
|
520
615
|
this.onCatalog(this.def.id, result.configOptions);
|
|
521
616
|
}
|
|
522
617
|
const session = {
|
|
@@ -541,7 +636,7 @@ export class AgentBridge {
|
|
|
541
636
|
},
|
|
542
637
|
}
|
|
543
638
|
: {}),
|
|
544
|
-
...(result.configOptions
|
|
639
|
+
...(usableConfigOptions(result.configOptions) ? { configOptions: result.configOptions } : {}),
|
|
545
640
|
};
|
|
546
641
|
this.sessions.set(session.sessionId, session);
|
|
547
642
|
this.acpIndex.set(session.acpSessionId, session);
|
|
@@ -559,6 +654,15 @@ export class AgentBridge {
|
|
|
559
654
|
* Spawn a throwaway ACP session purely to read its `configOptions` (the
|
|
560
655
|
* model catalog probe). Deliberately bypasses the live-session bookkeeping
|
|
561
656
|
* so probe sessions never surface in the browser session list.
|
|
657
|
+
*
|
|
658
|
+
* THE PROBE SENDS THE AGENT'S OWN MCP LIST, not an empty one. Measured
|
|
659
|
+
* against codebuddy: the servers handed to `session/new` land in the
|
|
660
|
+
* agent PROCESS's dynamic MCP scope, so a probe that sent an empty list
|
|
661
|
+
* would REPLACE that scope and drop the MCP servers of the live sessions
|
|
662
|
+
* on the same connection (the agent logged
|
|
663
|
+
* `serverManager.delete("injected")` and the live session's
|
|
664
|
+
* `mcp__injected__*` tools disappeared). One list per agent process is the
|
|
665
|
+
* only shape that keeps every session of that process consistent.
|
|
562
666
|
*/
|
|
563
667
|
async probeConfigOptions() {
|
|
564
668
|
// A probe is the reason an agent process can be resident with NOTHING else
|
|
@@ -571,7 +675,7 @@ export class AgentBridge {
|
|
|
571
675
|
if (conn === undefined)
|
|
572
676
|
throw new AcpError('agent_unreachable', 'acp: connection is not open');
|
|
573
677
|
const resolved = resolveSessionCwd(this.def, undefined, p => realpathSync(p));
|
|
574
|
-
const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.new, { cwd: resolved, mcpServers:
|
|
678
|
+
const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.new, { cwd: resolved, mcpServers: toAcpMcpServers(this.def.mcpServers) }), 'session/new');
|
|
575
679
|
await conn.agent.request(methods.agent.session.close, { sessionId: result.sessionId }).catch(() => { });
|
|
576
680
|
return Array.isArray(result.configOptions) ? result.configOptions : [];
|
|
577
681
|
}
|
|
@@ -626,6 +730,13 @@ export class AgentBridge {
|
|
|
626
730
|
// own turn apart from a later one.
|
|
627
731
|
session.cancelRequestedAtTurn = session.turnSeq;
|
|
628
732
|
await this.conn.agent.notify(methods.agent.session.cancel, { sessionId: session.acpSessionId }).catch(() => { });
|
|
733
|
+
// The ACP spec's counterpart to the notification we just sent: a client that
|
|
734
|
+
// cancels MUST answer every pending `session/request_permission` with
|
|
735
|
+
// `cancelled`. Leaving them open would hold the agent's tool call blocked on
|
|
736
|
+
// an answer that can no longer arrive — the turn is over, the human's
|
|
737
|
+
// decision is "stop". OmniTerm does the same from `AcpClient::cancel`
|
|
738
|
+
// (`PermissionManager::cancel_all`).
|
|
739
|
+
this.settlePermissions(session);
|
|
629
740
|
this.armCancelFallback(session);
|
|
630
741
|
}
|
|
631
742
|
/**
|
|
@@ -719,8 +830,8 @@ export class AgentBridge {
|
|
|
719
830
|
// is in flight, and let the browser drop its blocks/watermark first.
|
|
720
831
|
this.acpIndex.set(session.acpSessionId, session);
|
|
721
832
|
this.hub.broadcast({ type: 'session/reset', sessionId: session.sessionId, session: snapshot(session) });
|
|
722
|
-
const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.load, { sessionId: session.acpSessionId, cwd: resolved, mcpServers:
|
|
723
|
-
if (this.onCatalog !== undefined && result.configOptions
|
|
833
|
+
const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.load, { sessionId: session.acpSessionId, cwd: resolved, mcpServers: toAcpMcpServers(this.def.mcpServers) }), 'session/load');
|
|
834
|
+
if (this.onCatalog !== undefined && usableConfigOptions(result.configOptions)) {
|
|
724
835
|
this.onCatalog(this.def.id, result.configOptions);
|
|
725
836
|
}
|
|
726
837
|
session.modes = result.modes !== undefined && result.modes !== null
|
|
@@ -733,7 +844,7 @@ export class AgentBridge {
|
|
|
733
844
|
})),
|
|
734
845
|
}
|
|
735
846
|
: session.modes;
|
|
736
|
-
if (
|
|
847
|
+
if (usableConfigOptions(result.configOptions))
|
|
737
848
|
session.configOptions = result.configOptions;
|
|
738
849
|
this.sessions.set(session.sessionId, session);
|
|
739
850
|
this.released.delete(session.sessionId);
|
|
@@ -825,15 +936,26 @@ export class AgentBridge {
|
|
|
825
936
|
const conn = this.conn;
|
|
826
937
|
if (conn === undefined)
|
|
827
938
|
throw new AcpError('agent_unreachable', 'acp: connection is not open');
|
|
828
|
-
|
|
939
|
+
// A toggle must say so on the wire: the request union discriminates on
|
|
940
|
+
// `type`, and a bare boolean `value` is not a select value id.
|
|
941
|
+
const params = typeof value === 'boolean'
|
|
942
|
+
? { sessionId: session.acpSessionId, configId, value, type: 'boolean' }
|
|
943
|
+
: { sessionId: session.acpSessionId, configId, value };
|
|
944
|
+
const result = await this.runCommand(() => conn.agent.request(methods.agent.session.setConfigOption, params));
|
|
829
945
|
// The response carries the full authoritative option set; some agents
|
|
830
946
|
// (pi-acp observed) never re-notify after a client-driven set. It feeds the
|
|
831
|
-
// SESSION snapshot
|
|
832
|
-
//
|
|
833
|
-
//
|
|
834
|
-
//
|
|
835
|
-
|
|
947
|
+
// SESSION snapshot first — `currentValue` in a per-session answer describes
|
|
948
|
+
// this session's pick, and promoting it to the catalog would turn the
|
|
949
|
+
// seat's own choice into `reasoning.defaultEffort` for every other
|
|
950
|
+
// conversation (and back onto itself next turn). The option SHAPE still
|
|
951
|
+
// reaches the agent-level catalog (`shapeOnly`): this response is often the
|
|
952
|
+
// only place an agent publishes how its option set changes with the model
|
|
953
|
+
// (opencode offers `thought_level` just for models with variants), and the
|
|
954
|
+
// composer's thought chip is driven by the catalog.
|
|
955
|
+
if (usableConfigOptions(result.configOptions)) {
|
|
836
956
|
session.configOptions = result.configOptions;
|
|
957
|
+
this.onCatalog?.(this.def.id, result.configOptions, true);
|
|
958
|
+
}
|
|
837
959
|
this.publishMeta(session);
|
|
838
960
|
}
|
|
839
961
|
publishMeta(session) {
|
|
@@ -946,6 +1068,11 @@ export class AgentBridge {
|
|
|
946
1068
|
resolve(outcome.outcome === 'selected'
|
|
947
1069
|
? { outcome: { outcome: 'selected', optionId: outcome.optionId } }
|
|
948
1070
|
: { outcome: { outcome: 'cancelled' } });
|
|
1071
|
+
}, {
|
|
1072
|
+
// Kept for the reconnect replay: a client that connects after the ask
|
|
1073
|
+
// was broadcast must be re-shown the very card it has to answer.
|
|
1074
|
+
toolCall,
|
|
1075
|
+
options: options.map((o) => ({ optionId: o.optionId, name: o.name, kind: o.kind })),
|
|
949
1076
|
});
|
|
950
1077
|
// Deadline (`permissionTimeoutMs`): an approval left unanswered is
|
|
951
1078
|
// answered `cancelled` on the human's behalf instead of hanging the turn
|
|
@@ -953,14 +1080,22 @@ export class AgentBridge {
|
|
|
953
1080
|
// exception is a DEGRADED native route: the conversation just failed to
|
|
954
1081
|
// show the question, so waiting forever would hang the turn behind an
|
|
955
1082
|
// ask nobody can see there, and the fallback deadline applies instead
|
|
956
|
-
// (see `BridgeTuning.permissionFallbackTimeoutMs`).
|
|
1083
|
+
// (see `BridgeTuning.permissionFallbackTimeoutMs`). `native-timeout` is
|
|
1084
|
+
// the same degradation reached from the other side: the host took the
|
|
1085
|
+
// ask and never answered it.
|
|
957
1086
|
const timeoutMs = this.permissionTimeoutMs > 0
|
|
958
1087
|
? this.permissionTimeoutMs
|
|
959
|
-
: native.reason === 'unavailable'
|
|
1088
|
+
: native.reason === 'unavailable' || native.reason === 'native-timeout'
|
|
1089
|
+
? this.permissionFallbackTimeoutMs
|
|
1090
|
+
: 0;
|
|
960
1091
|
// Recorded before the broadcast: the hub is synchronous, and the chat
|
|
961
1092
|
// line `streamPrompt` folds from this frame words itself by the path
|
|
962
1093
|
// this ask actually took.
|
|
963
|
-
this.ledgerFallbacks.set(requestId, {
|
|
1094
|
+
this.ledgerFallbacks.set(requestId, {
|
|
1095
|
+
reason: native.reason,
|
|
1096
|
+
timeoutMs,
|
|
1097
|
+
...(native.reason === 'native-timeout' ? { waitedMs: this.nativeApprovalTimeoutMs } : {}),
|
|
1098
|
+
});
|
|
964
1099
|
if (timeoutMs > 0) {
|
|
965
1100
|
timer = setTimeout(() => {
|
|
966
1101
|
if (!this.permissions.has(requestId))
|
|
@@ -969,7 +1104,10 @@ export class AgentBridge {
|
|
|
969
1104
|
type: 'agent/notice',
|
|
970
1105
|
agentId: this.def.id,
|
|
971
1106
|
level: 'info',
|
|
972
|
-
|
|
1107
|
+
// Name what was missed, the way a human needs it: which operation
|
|
1108
|
+
// sat unanswered, and for how long (OmniTerm's reaper writes the
|
|
1109
|
+
// same "you missed this" notice before it acts).
|
|
1110
|
+
text: `acp: permission request for "${permissionToolName(toolCall)}" timed out after ${String(Math.round(timeoutMs / 1000))}s; answered "cancelled"`,
|
|
973
1111
|
});
|
|
974
1112
|
this.permissions.respond(requestId, { outcome: 'cancelled' });
|
|
975
1113
|
}, timeoutMs);
|
|
@@ -1000,8 +1138,19 @@ export class AgentBridge {
|
|
|
1000
1138
|
* open turn, so a session started over `/acp` (no chat turn) cannot be
|
|
1001
1139
|
* routed and must stay on the ledger path.
|
|
1002
1140
|
*
|
|
1003
|
-
*
|
|
1004
|
-
*
|
|
1141
|
+
* The ask is raced against {@link BridgeTuning.nativeApprovalTimeoutMs}. A
|
|
1142
|
+
* routed ask has exactly one answer entry — the composer takeover — and no
|
|
1143
|
+
* broadcast at all, so a host that takes the question and never renders it
|
|
1144
|
+
* would leave the turn hanging behind something invisible. Past the deadline
|
|
1145
|
+
* the ask is WITHDRAWN (the signal handed to the host aborts, which settles
|
|
1146
|
+
* its waterfall `cancelled` and drops the composer panel) and reported as
|
|
1147
|
+
* `native-timeout`, sending the question to the ledger where the chat line
|
|
1148
|
+
* and the settings card are both visible. Withdrawing is not answering: a
|
|
1149
|
+
* human mid-decision finds the same question in the settings panel.
|
|
1150
|
+
*
|
|
1151
|
+
* No deadline is armed on the ledger paths (`no-requester`,
|
|
1152
|
+
* `no-chat-session`): those never reach the host, and their answer entry is
|
|
1153
|
+
* the settings panel the chat line already points at.
|
|
1005
1154
|
*
|
|
1006
1155
|
* @returns the agent-facing response, or the reason the ask must fall back
|
|
1007
1156
|
* to the ledger (see {@link LedgerFallbackReason}).
|
|
@@ -1012,20 +1161,52 @@ export class AgentBridge {
|
|
|
1012
1161
|
return { reason: 'no-requester' };
|
|
1013
1162
|
if (session.dshSessionId === undefined)
|
|
1014
1163
|
return { reason: 'no-chat-session' };
|
|
1164
|
+
// One signal carries both withdrawals: the agent's own cancel (whatever
|
|
1165
|
+
// aborted the ACP request) and the deadline. The host's waterfall races its
|
|
1166
|
+
// answerers against this signal, so aborting is a clean withdrawal rather
|
|
1167
|
+
// than a leak.
|
|
1168
|
+
const withdraw = new AbortController();
|
|
1169
|
+
const askSignal = signal === undefined ? withdraw.signal : AbortSignal.any([signal, withdraw.signal]);
|
|
1170
|
+
let timer;
|
|
1171
|
+
let timedOut = false;
|
|
1172
|
+
const deadline = this.nativeApprovalTimeoutMs > 0
|
|
1173
|
+
? new Promise(resolve => {
|
|
1174
|
+
timer = setTimeout(() => {
|
|
1175
|
+
timedOut = true;
|
|
1176
|
+
withdraw.abort();
|
|
1177
|
+
resolve({ timedOut: true });
|
|
1178
|
+
}, this.nativeApprovalTimeoutMs);
|
|
1179
|
+
timer.unref?.();
|
|
1180
|
+
})
|
|
1181
|
+
: undefined;
|
|
1015
1182
|
let outcome;
|
|
1016
1183
|
try {
|
|
1017
|
-
|
|
1184
|
+
const asked = requester({
|
|
1018
1185
|
dshSessionId: session.dshSessionId,
|
|
1019
1186
|
agentId: this.def.id,
|
|
1020
1187
|
toolName: permissionToolName(toolCall),
|
|
1021
|
-
|
|
1188
|
+
signal: askSignal,
|
|
1022
1189
|
});
|
|
1190
|
+
// A withdrawn ask settles on the host's own schedule; dropping it here
|
|
1191
|
+
// must not surface as an unhandled rejection later.
|
|
1192
|
+
asked.catch(() => { });
|
|
1193
|
+
const settled = deadline === undefined
|
|
1194
|
+
? await asked.then(o => ({ outcome: o }))
|
|
1195
|
+
: await Promise.race([asked.then(o => ({ outcome: o })), deadline]);
|
|
1196
|
+
// The flag, not the race winner, decides: the host may settle `cancelled`
|
|
1197
|
+
// from the very abort the deadline caused, and that is still a timeout.
|
|
1198
|
+
if (timedOut || 'timedOut' in settled)
|
|
1199
|
+
return { reason: 'native-timeout' };
|
|
1200
|
+
outcome = settled.outcome;
|
|
1023
1201
|
}
|
|
1024
1202
|
catch {
|
|
1025
1203
|
// Synchronous throw or rejection, either way: the ask falls back to the
|
|
1026
1204
|
// ledger. An approval failure must never break the agent's turn.
|
|
1027
1205
|
outcome = 'unavailable';
|
|
1028
1206
|
}
|
|
1207
|
+
finally {
|
|
1208
|
+
clearTimeout(timer);
|
|
1209
|
+
}
|
|
1029
1210
|
if (outcome === 'unavailable')
|
|
1030
1211
|
return { reason: 'unavailable' };
|
|
1031
1212
|
const response = nativeOutcomeResponse(outcome, options);
|
|
@@ -1115,7 +1296,10 @@ export class AgentBridge {
|
|
|
1115
1296
|
session.modes = { ...session.modes, currentModeId: update.currentModeId };
|
|
1116
1297
|
break;
|
|
1117
1298
|
case 'config_option_update':
|
|
1118
|
-
|
|
1299
|
+
// Empty answers never overwrite the last known set — see `usableConfigOptions`.
|
|
1300
|
+
if (!usableConfigOptions(update.configOptions))
|
|
1301
|
+
break;
|
|
1302
|
+
session.configOptions = update.configOptions;
|
|
1119
1303
|
this.onCatalog?.(this.def.id, session.configOptions);
|
|
1120
1304
|
break;
|
|
1121
1305
|
case 'available_commands_update':
|
|
@@ -1151,9 +1335,31 @@ export class AgentBridge {
|
|
|
1151
1335
|
}
|
|
1152
1336
|
}
|
|
1153
1337
|
/** Human-facing identity of an ACP tool call (the approval headline). */
|
|
1154
|
-
|
|
1338
|
+
/**
|
|
1339
|
+
* Human-facing name of the tool an ask is about, for the chat line and the
|
|
1340
|
+
* timeout notice.
|
|
1341
|
+
*
|
|
1342
|
+
* The ACP `toolCall.title` is the declared field, but it is OPTIONAL and real
|
|
1343
|
+
* agents omit it: codebuddy puts the name in `_meta["codebuddy.ai/toolName"]`
|
|
1344
|
+
* (observed live — without the fallback the notice reads `for "tool call"`,
|
|
1345
|
+
* which tells the human nothing about what they missed). So read `title` first,
|
|
1346
|
+
* then any `_meta` key whose last segment is `toolName`, then the standard
|
|
1347
|
+
* `kind`, and only then give up with the generic phrase. Nothing is invented:
|
|
1348
|
+
* every step reads a key the agent itself declared.
|
|
1349
|
+
*/
|
|
1350
|
+
export function permissionToolName(toolCall) {
|
|
1155
1351
|
const row = (typeof toolCall === 'object' && toolCall !== null ? toolCall : {});
|
|
1156
|
-
|
|
1352
|
+
if (typeof row.title === 'string' && row.title !== '')
|
|
1353
|
+
return row.title;
|
|
1354
|
+
const meta = (typeof row._meta === 'object' && row._meta !== null ? row._meta : {});
|
|
1355
|
+
for (const [key, value] of Object.entries(meta)) {
|
|
1356
|
+
const segment = key.split('/').at(-1) ?? key;
|
|
1357
|
+
if (segment.toLowerCase() === 'toolname' && typeof value === 'string' && value !== '')
|
|
1358
|
+
return value;
|
|
1359
|
+
}
|
|
1360
|
+
if (typeof row.kind === 'string' && row.kind !== '')
|
|
1361
|
+
return row.kind;
|
|
1362
|
+
return 'tool call';
|
|
1157
1363
|
}
|
|
1158
1364
|
/**
|
|
1159
1365
|
* Translate a native approval outcome into the ACP option the agent must see.
|
|
@@ -2271,13 +2477,24 @@ class TurnStreamController {
|
|
|
2271
2477
|
// The line must not point at a place with no answer entry, and the bridge
|
|
2272
2478
|
// knows which path this ask took (`ledgerFallbackOf`, recorded before the
|
|
2273
2479
|
// frame was broadcast). On the DESIGNED ledger paths the settings panel's
|
|
2274
|
-
// card is the answer entry. A DEGRADED native route carries a deadline
|
|
2480
|
+
// card is the answer entry. A DEGRADED native route carries a deadline and
|
|
2481
|
+
// has to say why the conversation is not showing the question — as an
|
|
2482
|
+
// OBSERVED FACT, not a guessed cause: "no answer within Ns" is true whether
|
|
2483
|
+
// the human was slow or the host never rendered the question.
|
|
2275
2484
|
const fallback = this.bridge.ledgerFallbackOf(event.requestId);
|
|
2276
|
-
|
|
2277
|
-
?
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2485
|
+
const why = fallback === undefined
|
|
2486
|
+
? undefined
|
|
2487
|
+
: fallback.reason === 'unavailable'
|
|
2488
|
+
? 'the approval service is unavailable'
|
|
2489
|
+
: fallback.reason === 'native-timeout'
|
|
2490
|
+
? `no answer in the conversation within ${String(Math.round((fallback.waitedMs ?? 0) / 1000))}s`
|
|
2491
|
+
: undefined;
|
|
2492
|
+
if (fallback === undefined || why === undefined) {
|
|
2493
|
+
return `\n> 🔐 [Permission Required: ${title}] - please approve in Settings\n`;
|
|
2494
|
+
}
|
|
2495
|
+
return fallback.timeoutMs > 0
|
|
2496
|
+
? `\n> 🔐 [Permission Required: ${title}] - ${why}; approve in Settings within ${String(Math.round(fallback.timeoutMs / 1000))}s or this request is cancelled\n`
|
|
2497
|
+
: `\n> 🔐 [Permission Required: ${title}] - ${why}; please approve in Settings\n`;
|
|
2281
2498
|
}
|
|
2282
2499
|
}
|
|
2283
2500
|
/** Chunk text shared by the controller's fold (string / object / array forms). */
|
|
@@ -2315,13 +2532,47 @@ export class BridgeManager {
|
|
|
2315
2532
|
configStoragePath;
|
|
2316
2533
|
catalogStoragePath;
|
|
2317
2534
|
probeModelsOnBoot;
|
|
2318
|
-
tuning;
|
|
2319
2535
|
bridges = new Map();
|
|
2320
2536
|
defsMap = new Map();
|
|
2321
2537
|
/** The plugin's own default ACP agent id (persisted; see `persistAgents`). */
|
|
2322
2538
|
defaultAgentId;
|
|
2323
2539
|
/** The agent tag's halo preference (persisted; see `persistAgents`). */
|
|
2324
2540
|
tagHaloSetting = { ...DEFAULT_TAG_HALO };
|
|
2541
|
+
/**
|
|
2542
|
+
* What `cordis.patch.yml` asked for, by key — ONLY the keys it actually
|
|
2543
|
+
* names. This is the deployment baseline, kept apart from
|
|
2544
|
+
* {@link deadlineOverride} so the settings card can tell the user which of
|
|
2545
|
+
* the two is winning. Empty when the file says nothing about either window,
|
|
2546
|
+
* in which case {@link baselineDeadlines} (the built-in default) applies.
|
|
2547
|
+
*/
|
|
2548
|
+
configuredDeadlines = {};
|
|
2549
|
+
/**
|
|
2550
|
+
* The value each deadline had before any override — the tuning this manager
|
|
2551
|
+
* was constructed with, which already carries the validated config file (the
|
|
2552
|
+
* host entry passes `validateConfig`'s output) or the built-in default.
|
|
2553
|
+
*
|
|
2554
|
+
* Held separately from {@link configuredDeadlines} because the two answer
|
|
2555
|
+
* different questions: this one is what RUNS when nothing is overridden, that
|
|
2556
|
+
* one is what the config file NAMED (which the card shows as the reset target
|
|
2557
|
+
* and a bare default does not deserve). A manager built without tuning at all
|
|
2558
|
+
* — a direct consumer, a test — gets `DEFAULT_TUNING` here and stays correct.
|
|
2559
|
+
*/
|
|
2560
|
+
baselineDeadlines;
|
|
2561
|
+
/**
|
|
2562
|
+
* The settings page's own write, persisted with the agent registry. Merged
|
|
2563
|
+
* OVER {@link baselineDeadlines}: once a user picks a window in the UI, that
|
|
2564
|
+
* pick is what runs, and the config file becomes the value the reset button
|
|
2565
|
+
* returns to. Without this split a hand-edited `cordis.patch.yml` and a UI
|
|
2566
|
+
* write would fight with no way to tell which one is live.
|
|
2567
|
+
*/
|
|
2568
|
+
deadlineOverride = {};
|
|
2569
|
+
/**
|
|
2570
|
+
* Tuning in force, defaults applied. The two approval deadlines are rewritten
|
|
2571
|
+
* IN PLACE by {@link applyDeadlineTuning}, which is why this is a live object
|
|
2572
|
+
* rather than the constructor's parameter: every bridge built afterwards
|
|
2573
|
+
* inherits the rewrite instead of the boot-time value.
|
|
2574
|
+
*/
|
|
2575
|
+
tuning;
|
|
2325
2576
|
/**
|
|
2326
2577
|
* The in-flight (or just-settled) segmented turn of one DSH chat session.
|
|
2327
2578
|
* Keyed by chat session id because the continuation steps of a segmented
|
|
@@ -2368,7 +2619,18 @@ export class BridgeManager {
|
|
|
2368
2619
|
this.configStoragePath = configStoragePath;
|
|
2369
2620
|
this.catalogStoragePath = catalogStoragePath;
|
|
2370
2621
|
this.probeModelsOnBoot = probeModelsOnBoot;
|
|
2371
|
-
|
|
2622
|
+
// Held as a live object rather than the raw parameter: the two approval
|
|
2623
|
+
// deadlines are rewritten in place when the settings page writes them (see
|
|
2624
|
+
// `applyDeadlineTuning`), and every bridge built afterwards must inherit
|
|
2625
|
+
// the rewrite instead of the boot-time value.
|
|
2626
|
+
this.tuning = { ...DEFAULT_TUNING, ...tuning };
|
|
2627
|
+
// The pre-override deadline values, snapshotted before anything can rewrite
|
|
2628
|
+
// them. Everything else in `tuning` is fixed for the manager's lifetime, so
|
|
2629
|
+
// only this pair needs a snapshot.
|
|
2630
|
+
this.baselineDeadlines = {
|
|
2631
|
+
nativeApprovalTimeoutMs: this.tuning.nativeApprovalTimeoutMs,
|
|
2632
|
+
permissionFallbackTimeoutMs: this.tuning.permissionFallbackTimeoutMs,
|
|
2633
|
+
};
|
|
2372
2634
|
for (const def of defs) {
|
|
2373
2635
|
this.defsMap.set(def.id, def);
|
|
2374
2636
|
}
|
|
@@ -2381,6 +2643,9 @@ export class BridgeManager {
|
|
|
2381
2643
|
}
|
|
2382
2644
|
this.loadPersistedAgents();
|
|
2383
2645
|
this.loadPersistedCatalogs();
|
|
2646
|
+
// A persisted deadline override must reach the tuning the bridges are built
|
|
2647
|
+
// from, not just the settings card's readout.
|
|
2648
|
+
this.applyDeadlineTuning();
|
|
2384
2649
|
// The request-path rewrite falls back to this default for a conversation
|
|
2385
2650
|
// the user never bound explicitly — the same agent the composer chip shows.
|
|
2386
2651
|
routeBindingRegistry().defaultModel = this.defaultAgent();
|
|
@@ -2401,6 +2666,19 @@ export class BridgeManager {
|
|
|
2401
2666
|
approvalRouting() {
|
|
2402
2667
|
return this.approvalRequester !== undefined;
|
|
2403
2668
|
}
|
|
2669
|
+
/**
|
|
2670
|
+
* Every outstanding ledger ask, across all agents, with the view the browser
|
|
2671
|
+
* was shown it — the source of the reconnect replay (see
|
|
2672
|
+
* `permissionReplayFrames`). Asks on the native route are deliberately
|
|
2673
|
+
* absent: they broadcast nothing and their answer entry is the composer
|
|
2674
|
+
* takeover the client already holds.
|
|
2675
|
+
*/
|
|
2676
|
+
pendingPermissionViews() {
|
|
2677
|
+
const views = [];
|
|
2678
|
+
for (const bridge of this.bridges.values())
|
|
2679
|
+
views.push(...bridge.permissions.pendingViews());
|
|
2680
|
+
return views;
|
|
2681
|
+
}
|
|
2404
2682
|
/**
|
|
2405
2683
|
* Let the host resolve a chat session's workspace root — the sidebar's
|
|
2406
2684
|
* selected workspace, which the agent's session and its process both run in.
|
|
@@ -2864,8 +3142,19 @@ export class BridgeManager {
|
|
|
2864
3142
|
if (option === undefined) {
|
|
2865
3143
|
throw new AcpError('unknown_config', `acp: agent "${bound}" publishes no extra config option "${configId}"`);
|
|
2866
3144
|
}
|
|
2867
|
-
if (
|
|
2868
|
-
|
|
3145
|
+
if (option.kind === 'boolean') {
|
|
3146
|
+
// The browser sends 'true' / 'false' from a checkbox row; the wire and
|
|
3147
|
+
// the persisted pick both use the real boolean.
|
|
3148
|
+
const toggle = typeof value === 'boolean'
|
|
3149
|
+
? value
|
|
3150
|
+
: value === 'true' ? true : value === 'false' ? false : undefined;
|
|
3151
|
+
if (toggle === undefined) {
|
|
3152
|
+
throw new AcpError('bad_config', `acp: "${String(value)}" is not a boolean value of config option "${configId}"`);
|
|
3153
|
+
}
|
|
3154
|
+
value = toggle;
|
|
3155
|
+
}
|
|
3156
|
+
else if (typeof value !== 'string' || !option.choices.some(choice => choice.value === value)) {
|
|
3157
|
+
throw new AcpError('bad_config', `acp: "${String(value)}" is not a value of config option "${configId}"`);
|
|
2869
3158
|
}
|
|
2870
3159
|
const current = this.chatAgentIntent.get(dshSessionId);
|
|
2871
3160
|
const extraConfigs = current?.agentId === bound ? { ...current.extraConfigs } : {};
|
|
@@ -2962,12 +3251,17 @@ export class BridgeManager {
|
|
|
2962
3251
|
});
|
|
2963
3252
|
continue;
|
|
2964
3253
|
}
|
|
2965
|
-
|
|
3254
|
+
// A toggle's truth is its boolean itself; a select's is membership in
|
|
3255
|
+
// the choices the agent still offers.
|
|
3256
|
+
const usable = option.kind === 'boolean'
|
|
3257
|
+
? typeof value === 'boolean'
|
|
3258
|
+
: typeof value === 'string' && option.choices.some(entry => entry.value === value);
|
|
3259
|
+
if (!usable) {
|
|
2966
3260
|
this.hub.broadcast({
|
|
2967
3261
|
type: 'agent/notice',
|
|
2968
3262
|
agentId: session.agentId,
|
|
2969
3263
|
level: 'error',
|
|
2970
|
-
text: `acp: agent "${session.agentId}" no longer offers "${value}" for config "${configId}"; keeping its own`,
|
|
3264
|
+
text: `acp: agent "${session.agentId}" no longer offers "${String(value)}" for config "${configId}"; keeping its own`,
|
|
2971
3265
|
});
|
|
2972
3266
|
continue;
|
|
2973
3267
|
}
|
|
@@ -3169,6 +3463,17 @@ export class BridgeManager {
|
|
|
3169
3463
|
catch {
|
|
3170
3464
|
this.tagHaloSetting = { ...DEFAULT_TAG_HALO };
|
|
3171
3465
|
}
|
|
3466
|
+
// The settings page's approval-deadline override. Re-validated on every
|
|
3467
|
+
// boot because the state file is user- and version-editable: an
|
|
3468
|
+
// unreadable value must not poison the deadline, and a value the
|
|
3469
|
+
// validator accepts must be the value that runs (a UI write that did
|
|
3470
|
+
// not survive a restart would read as a broken setting).
|
|
3471
|
+
try {
|
|
3472
|
+
this.deadlineOverride = validateApprovalDeadlines(parsed.approvalDeadlines ?? {});
|
|
3473
|
+
}
|
|
3474
|
+
catch {
|
|
3475
|
+
this.deadlineOverride = {};
|
|
3476
|
+
}
|
|
3172
3477
|
this.loadPersistedChoices(parsed.conversations);
|
|
3173
3478
|
}
|
|
3174
3479
|
}
|
|
@@ -3194,7 +3499,7 @@ export class BridgeManager {
|
|
|
3194
3499
|
continue;
|
|
3195
3500
|
const configs = extraConfigs !== null && typeof extraConfigs === 'object' && !Array.isArray(extraConfigs)
|
|
3196
3501
|
? Object.fromEntries(Object.entries(extraConfigs)
|
|
3197
|
-
.filter(([, value]) => typeof value === 'string' && value !== ''))
|
|
3502
|
+
.filter(([, value]) => typeof value === 'boolean' || (typeof value === 'string' && value !== '')))
|
|
3198
3503
|
: {};
|
|
3199
3504
|
this.chatAgentIntent.set(dshSessionId, {
|
|
3200
3505
|
agentId,
|
|
@@ -3223,6 +3528,11 @@ export class BridgeManager {
|
|
|
3223
3528
|
...(this.defaultAgentId === undefined ? {} : { defaultAgentId: this.defaultAgentId }),
|
|
3224
3529
|
tagHalo: this.tagHaloSetting,
|
|
3225
3530
|
agents: [...this.defsMap.values()],
|
|
3531
|
+
// The settings page's approval deadlines. Absent while none was ever
|
|
3532
|
+
// written, so a fresh deployment's state file stays exactly as it was.
|
|
3533
|
+
...(Object.keys(this.deadlineOverride).length === 0
|
|
3534
|
+
? {}
|
|
3535
|
+
: { approvalDeadlines: { ...this.deadlineOverride } }),
|
|
3226
3536
|
// The per-conversation picks: agent + model + thought level + mode +
|
|
3227
3537
|
// extra config values chosen in the composer, including for
|
|
3228
3538
|
// conversations with no live session yet.
|
|
@@ -3359,6 +3669,9 @@ export class BridgeManager {
|
|
|
3359
3669
|
* current `parseCatalogOptions` has now seen this agent's real answer.
|
|
3360
3670
|
*/
|
|
3361
3671
|
absorbCatalog(agentId, configOptions) {
|
|
3672
|
+
// Empty / absurd answers never overwrite known state — see `usableConfigOptions`.
|
|
3673
|
+
if (!usableConfigOptions(configOptions))
|
|
3674
|
+
return this.catalogs.get(agentId) ?? { catalog: {}, updatedAt: 0 };
|
|
3362
3675
|
const catalog = parseCatalogOptions(configOptions);
|
|
3363
3676
|
this.catalogStale.delete(agentId);
|
|
3364
3677
|
const existing = this.catalogs.get(agentId);
|
|
@@ -3386,6 +3699,30 @@ export class BridgeManager {
|
|
|
3386
3699
|
this.broadcastExposure(agentId);
|
|
3387
3700
|
return entry;
|
|
3388
3701
|
}
|
|
3702
|
+
/**
|
|
3703
|
+
* Fold a per-session option answer's SHAPE into the agent's catalog.
|
|
3704
|
+
*
|
|
3705
|
+
* The counterpart of {@link absorbCatalog} for `session/set_config_option`
|
|
3706
|
+
* responses: which options exist and which choices they offer is agent
|
|
3707
|
+
* knowledge the seat must show (the composer's thought chip only renders
|
|
3708
|
+
* when the catalog has `thought_level`, and some agents publish it only in
|
|
3709
|
+
* the answer to a model change), while a per-session `currentValue` stays
|
|
3710
|
+
* out of it. No auto-exposure and no staleness discharge either — this is
|
|
3711
|
+
* one session's answer, not the agent-level observation those marks track.
|
|
3712
|
+
*/
|
|
3713
|
+
absorbCatalogShape(agentId, configOptions) {
|
|
3714
|
+
if (!usableConfigOptions(configOptions))
|
|
3715
|
+
return;
|
|
3716
|
+
const observed = parseCatalogOptions(configOptions);
|
|
3717
|
+
const existing = this.catalogs.get(agentId);
|
|
3718
|
+
const catalog = mergeCatalogShape(existing?.catalog, observed);
|
|
3719
|
+
if (existing !== undefined && JSON.stringify(existing.catalog) === JSON.stringify(catalog))
|
|
3720
|
+
return;
|
|
3721
|
+
const entry = { catalog, updatedAt: Date.now() };
|
|
3722
|
+
this.catalogs.set(agentId, entry);
|
|
3723
|
+
this.persistCatalogs();
|
|
3724
|
+
this.hub.broadcast({ type: 'catalog/updated', agentId, updatedAt: entry.updatedAt });
|
|
3725
|
+
}
|
|
3389
3726
|
/**
|
|
3390
3727
|
* Remember the slash commands an agent declared in a real session.
|
|
3391
3728
|
*
|
|
@@ -3418,11 +3755,12 @@ export class BridgeManager {
|
|
|
3418
3755
|
* selection would resurrect a model the user deliberately unchecked every
|
|
3419
3756
|
* time the agent repeats its catalog. The first observation has no baseline
|
|
3420
3757
|
* (`before === undefined`), so it adds nothing; the user can still add the
|
|
3421
|
-
* whole list with one click in the settings card.
|
|
3758
|
+
* whole list with one click in the settings card. Discovery is ON unless the
|
|
3759
|
+
* agent definition explicitly opts out with `autoDiscoverModels: false`.
|
|
3422
3760
|
*/
|
|
3423
3761
|
autoExposeNewModels(agentId, next, before) {
|
|
3424
3762
|
const def = this.defsMap.get(agentId);
|
|
3425
|
-
if (def
|
|
3763
|
+
if (def === undefined || def.autoDiscoverModels === false || before === undefined)
|
|
3426
3764
|
return false;
|
|
3427
3765
|
const known = new Set((before.model?.choices ?? []).map(choice => choice.value));
|
|
3428
3766
|
const exposed = new Set(def.exposedModels ?? []);
|
|
@@ -3444,7 +3782,7 @@ export class BridgeManager {
|
|
|
3444
3782
|
type: 'agent/exposure',
|
|
3445
3783
|
agentId,
|
|
3446
3784
|
exposedModels: [...(def.exposedModels ?? [])],
|
|
3447
|
-
autoDiscoverModels: def.autoDiscoverModels
|
|
3785
|
+
autoDiscoverModels: def.autoDiscoverModels !== false,
|
|
3448
3786
|
});
|
|
3449
3787
|
}
|
|
3450
3788
|
catalogFor(agentId) {
|
|
@@ -3618,6 +3956,7 @@ export class BridgeManager {
|
|
|
3618
3956
|
extras: extraOptions.map(option => ({
|
|
3619
3957
|
configId: option.configId,
|
|
3620
3958
|
name: option.name,
|
|
3959
|
+
...(option.kind === 'boolean' ? { kind: 'boolean' } : {}),
|
|
3621
3960
|
...(option.currentValue === null ? {} : { currentValue: option.currentValue }),
|
|
3622
3961
|
choices: option.choices.map(choice => ({
|
|
3623
3962
|
id: choice.value,
|
|
@@ -3841,8 +4180,11 @@ export class BridgeManager {
|
|
|
3841
4180
|
if (bridge === undefined) {
|
|
3842
4181
|
bridge = this.makeBridge(def, (b, reason) => {
|
|
3843
4182
|
this.connectionLost(b, reason);
|
|
3844
|
-
}, (observedAgentId, configOptions) => {
|
|
3845
|
-
|
|
4183
|
+
}, (observedAgentId, configOptions, shapeOnly) => {
|
|
4184
|
+
if (shapeOnly)
|
|
4185
|
+
this.absorbCatalogShape(observedAgentId, configOptions);
|
|
4186
|
+
else
|
|
4187
|
+
this.absorbCatalog(observedAgentId, configOptions);
|
|
3846
4188
|
}, (observedAgentId, commands) => {
|
|
3847
4189
|
this.absorbCommands(observedAgentId, commands);
|
|
3848
4190
|
});
|
|
@@ -3970,16 +4312,17 @@ export class BridgeManager {
|
|
|
3970
4312
|
* Toggle auto-discovery of newly published models. Lightweight like
|
|
3971
4313
|
* `setExposedModels`: the def is mutated in place (live bridges share the
|
|
3972
4314
|
* object) and persisted; no process is restarted, and the current selection
|
|
3973
|
-
* is left exactly as it is.
|
|
4315
|
+
* is left exactly as it is. On is the default, so only the opt-out
|
|
4316
|
+
* (`false`) is written to the file.
|
|
3974
4317
|
*/
|
|
3975
4318
|
async setAutoDiscoverModels(agentId, autoDiscoverModels) {
|
|
3976
4319
|
const def = this.defsMap.get(agentId);
|
|
3977
4320
|
if (def === undefined)
|
|
3978
4321
|
throw new AcpError('unknown_agent', `acp: agent "${agentId}" is not configured`);
|
|
3979
4322
|
if (autoDiscoverModels)
|
|
3980
|
-
def.autoDiscoverModels = true;
|
|
3981
|
-
else
|
|
3982
4323
|
delete def.autoDiscoverModels;
|
|
4324
|
+
else
|
|
4325
|
+
def.autoDiscoverModels = false;
|
|
3983
4326
|
this.persistAgents();
|
|
3984
4327
|
this.broadcastExposure(agentId);
|
|
3985
4328
|
}
|
|
@@ -4183,6 +4526,79 @@ export class BridgeManager {
|
|
|
4183
4526
|
this.tagHaloSetting = validateTagHalo(halo);
|
|
4184
4527
|
this.persistAgents();
|
|
4185
4528
|
}
|
|
4529
|
+
/**
|
|
4530
|
+
* Declare the deployment baseline for the two approval deadlines — the values
|
|
4531
|
+
* `cordis.patch.yml` names, passed RAW (only the keys it actually contains).
|
|
4532
|
+
*
|
|
4533
|
+
* Kept separate from the validated tuning because "the file says 600000" and
|
|
4534
|
+
* "the file says nothing, so 300000 applies" are different facts: the settings
|
|
4535
|
+
* card shows the first as the reset target and the second as a built-in
|
|
4536
|
+
* default. Called once by the host entry, before any bridge exists — and it
|
|
4537
|
+
* also moves the baseline, so a manager built without tuning (a direct
|
|
4538
|
+
* consumer, a test) still runs what the deployment asked for.
|
|
4539
|
+
*/
|
|
4540
|
+
setConfiguredApprovalDeadlines(values) {
|
|
4541
|
+
this.configuredDeadlines = { ...values };
|
|
4542
|
+
if (values.nativeApprovalTimeoutMs !== undefined)
|
|
4543
|
+
this.baselineDeadlines.nativeApprovalTimeoutMs = values.nativeApprovalTimeoutMs;
|
|
4544
|
+
if (values.permissionFallbackTimeoutMs !== undefined)
|
|
4545
|
+
this.baselineDeadlines.permissionFallbackTimeoutMs = values.permissionFallbackTimeoutMs;
|
|
4546
|
+
this.applyDeadlineTuning();
|
|
4547
|
+
}
|
|
4548
|
+
/**
|
|
4549
|
+
* The approval deadlines in force, plus the two facts that explain a value the
|
|
4550
|
+
* user did not pick: the config-file baseline an override is outranking, and a
|
|
4551
|
+
* `permissionTimeoutMs` that overrides BOTH windows when non-zero. Both are
|
|
4552
|
+
* reported only when they are actually in play, so the card can stay silent
|
|
4553
|
+
* about a plain default.
|
|
4554
|
+
*/
|
|
4555
|
+
approvalDeadlines() {
|
|
4556
|
+
const deadlines = {
|
|
4557
|
+
nativeApprovalTimeoutMs: this.tuning.nativeApprovalTimeoutMs,
|
|
4558
|
+
permissionFallbackTimeoutMs: this.tuning.permissionFallbackTimeoutMs,
|
|
4559
|
+
};
|
|
4560
|
+
if (Object.keys(this.configuredDeadlines).length > 0)
|
|
4561
|
+
deadlines.configured = { ...this.configuredDeadlines };
|
|
4562
|
+
if (this.tuning.permissionTimeoutMs > 0)
|
|
4563
|
+
deadlines.permissionTimeoutMs = this.tuning.permissionTimeoutMs;
|
|
4564
|
+
return deadlines;
|
|
4565
|
+
}
|
|
4566
|
+
/**
|
|
4567
|
+
* Write one or both approval deadlines (omitted keys keep their value), or
|
|
4568
|
+
* drop the persisted override with `reset` so the config-file baseline — or
|
|
4569
|
+
* the built-in default — applies again.
|
|
4570
|
+
*
|
|
4571
|
+
* HOT: the rewrite reaches every live bridge, so a user changing the window
|
|
4572
|
+
* never has to kill a running session to see it take effect (the one thing an
|
|
4573
|
+
* `agent/save`-style restart would cost them). Only asks armed AFTER the
|
|
4574
|
+
* write see the new window; an ask already holding a timer keeps the one it
|
|
4575
|
+
* started with.
|
|
4576
|
+
*/
|
|
4577
|
+
async setApprovalDeadlines(patch, reset = false) {
|
|
4578
|
+
// Merged BEFORE validating, so the whole resulting override passes the same
|
|
4579
|
+
// gate the config file does (an override written by an older build is
|
|
4580
|
+
// re-gated here rather than trusted). `validateApprovalDeadlines` also drops
|
|
4581
|
+
// unknown keys, which is what keeps a stale payload from poisoning state.
|
|
4582
|
+
const merged = reset ? {} : { ...this.deadlineOverride, ...patch };
|
|
4583
|
+
this.deadlineOverride = validateApprovalDeadlines(merged);
|
|
4584
|
+
this.applyDeadlineTuning();
|
|
4585
|
+
this.persistAgents();
|
|
4586
|
+
}
|
|
4587
|
+
/**
|
|
4588
|
+
* Recompute the two deadlines from override → deployment baseline, and push
|
|
4589
|
+
* the result to every live bridge.
|
|
4590
|
+
*/
|
|
4591
|
+
applyDeadlineTuning() {
|
|
4592
|
+
const nativeApprovalTimeoutMs = this.deadlineOverride.nativeApprovalTimeoutMs
|
|
4593
|
+
?? this.baselineDeadlines.nativeApprovalTimeoutMs;
|
|
4594
|
+
const permissionFallbackTimeoutMs = this.deadlineOverride.permissionFallbackTimeoutMs
|
|
4595
|
+
?? this.baselineDeadlines.permissionFallbackTimeoutMs;
|
|
4596
|
+
this.tuning.nativeApprovalTimeoutMs = nativeApprovalTimeoutMs;
|
|
4597
|
+
this.tuning.permissionFallbackTimeoutMs = permissionFallbackTimeoutMs;
|
|
4598
|
+
const patch = { nativeApprovalTimeoutMs, permissionFallbackTimeoutMs };
|
|
4599
|
+
for (const bridge of this.bridges.values())
|
|
4600
|
+
bridge.applyApprovalDeadlines(patch);
|
|
4601
|
+
}
|
|
4186
4602
|
dshSessionMap = new Map();
|
|
4187
4603
|
/**
|
|
4188
4604
|
* What each DSH chat session has chosen for its ACP agent — the agent itself
|
|
@@ -4237,6 +4653,13 @@ export class BridgeManager {
|
|
|
4237
4653
|
const extraConfigs = {};
|
|
4238
4654
|
for (const option of catalog.extras ?? []) {
|
|
4239
4655
|
const value = option.currentValue;
|
|
4656
|
+
if (option.kind === 'boolean') {
|
|
4657
|
+
// The catalog encodes a toggle as 'true' / 'false'; the pick stores the
|
|
4658
|
+
// real boolean, exactly like an explicit checkbox click.
|
|
4659
|
+
if (value === 'true' || value === 'false')
|
|
4660
|
+
extraConfigs[option.configId] = value === 'true';
|
|
4661
|
+
continue;
|
|
4662
|
+
}
|
|
4240
4663
|
if (value !== null && value !== undefined && value !== '')
|
|
4241
4664
|
extraConfigs[option.configId] = value;
|
|
4242
4665
|
}
|
|
@@ -4290,7 +4713,11 @@ export class BridgeManager {
|
|
|
4290
4713
|
const option = options.extras?.find(entry => entry.configId === configId);
|
|
4291
4714
|
if (option === undefined)
|
|
4292
4715
|
continue;
|
|
4293
|
-
|
|
4716
|
+
// The fresh session's own option set is the authority for BOTH forms:
|
|
4717
|
+
// a toggle must still be a toggle, a select must still offer the value.
|
|
4718
|
+
if (option.kind === 'boolean'
|
|
4719
|
+
? typeof value !== 'boolean'
|
|
4720
|
+
: typeof value !== 'string' || (option.choices.length > 0 && !option.choices.some(choice => choice.value === value)))
|
|
4294
4721
|
continue;
|
|
4295
4722
|
extraConfigs[configId] = value;
|
|
4296
4723
|
changed = true;
|