@adhdev/daemon-core 0.9.82-rc.356 → 0.9.82-rc.357
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/dist/cli-adapter-types.d.ts +9 -0
- package/dist/index.js +111 -69
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +111 -69
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events-coordinator.d.ts +1 -0
- package/dist/providers/spec/fsm-driver.d.ts +1 -0
- package/dist/providers/spec/fsm-types.d.ts +30 -0
- package/package.json +2 -2
- package/src/cli-adapter-types.ts +9 -0
- package/src/mesh/mesh-events-coordinator.ts +128 -63
- package/src/providers/cli-provider-instance.ts +29 -5
- package/src/providers/spec/cli-adapter.ts +4 -2
- package/src/providers/spec/fsm-driver.ts +8 -3
- package/src/providers/spec/fsm-types.ts +35 -0
|
@@ -65,6 +65,7 @@ export declare function triggerMeshQueue(components: DaemonComponents, meshId: s
|
|
|
65
65
|
export declare function isMeshCoordinatorEvent(eventName: unknown): eventName is string;
|
|
66
66
|
export declare const MESH_FORCE_INJECT_EVENTS: ReadonlySet<string>;
|
|
67
67
|
export declare function shouldForceInjectMeshEvent(eventName: unknown): boolean;
|
|
68
|
+
export declare function buildRelayMetadataEvent(payload: Record<string, unknown>): Record<string, unknown>;
|
|
68
69
|
export declare function handleMeshForwardEvent(components: DaemonComponents, payload: Record<string, unknown>): {
|
|
69
70
|
success: boolean;
|
|
70
71
|
forwarded: number;
|
|
@@ -30,6 +30,27 @@ export interface FsmState {
|
|
|
30
30
|
/** Modal states (approval/picker) expose modal buttons in the UI and are
|
|
31
31
|
* treated as "interesting" — the dashboard surfaces them distinctly. */
|
|
32
32
|
modal?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* For a modal state, what KIND of modal it is — the semantic distinction the
|
|
35
|
+
* status field (always 'approval' for any modal, so the dashboard surfaces it)
|
|
36
|
+
* deliberately loses. The auto-approve worker uses this to decide whether it
|
|
37
|
+
* may answer the modal on the user's behalf:
|
|
38
|
+
*
|
|
39
|
+
* - 'approval' — a tool/command/trust consent prompt ("Allow Bash?",
|
|
40
|
+
* "Trust this folder?"). Auto-approve MAY fire (a background mesh worker
|
|
41
|
+
* should not stall on these).
|
|
42
|
+
* - 'picker' — a selection menu the user opened (/model, /mode, …). There
|
|
43
|
+
* is no "correct" answer to auto-pick; blindly selecting the first option
|
|
44
|
+
* silently changes the model/mode. Auto-approve must NOT fire — the user
|
|
45
|
+
* chooses.
|
|
46
|
+
* - 'confirm' — a non-consent yes/no the user must decide. Left to the user.
|
|
47
|
+
*
|
|
48
|
+
* Defaults to 'approval' for a modal state that omits it (preserves the
|
|
49
|
+
* pre-existing "auto-approve any modal" behaviour for un-migrated specs;
|
|
50
|
+
* picker/confirm states declare their kind explicitly). Non-modal states
|
|
51
|
+
* have no modal_kind.
|
|
52
|
+
*/
|
|
53
|
+
modal_kind?: 'approval' | 'picker' | 'confirm';
|
|
33
54
|
/** Status this state maps to for the dashboard/cli-adapter status field.
|
|
34
55
|
* One of: idle | generating | approval. Defaults: modal→approval,
|
|
35
56
|
* initial→idle, id==='busy'→generating, else idle. Explicit wins. */
|
|
@@ -142,3 +163,12 @@ export declare function outgoingTransitions(spec: CliSpecV4, stateId: string): F
|
|
|
142
163
|
/** Map a state to the dashboard status string, applying the documented
|
|
143
164
|
* defaults when `status` is not explicit. */
|
|
144
165
|
export declare function statusForState(state: FsmState): 'idle' | 'generating' | 'approval';
|
|
166
|
+
/**
|
|
167
|
+
* The modal kind for a state, or null when the state is not modal. A modal state
|
|
168
|
+
* that omits `modal_kind` defaults to 'approval' so the established
|
|
169
|
+
* auto-approve-any-modal behaviour is preserved for specs that have not yet
|
|
170
|
+
* declared a kind; picker/confirm states must opt out by declaring their kind.
|
|
171
|
+
* This is the value the cli-adapter carries on `activeModal.kind` and the
|
|
172
|
+
* auto-approve gate reads — see cli-provider-instance.maybeAutoApproveStatus.
|
|
173
|
+
*/
|
|
174
|
+
export declare function modalKindForState(state: FsmState): 'approval' | 'picker' | 'confirm' | null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.357",
|
|
4
4
|
"description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"author": "vilmire",
|
|
47
47
|
"license": "AGPL-3.0-or-later",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
49
|
+
"@adhdev/mesh-shared": "0.9.82-rc.357",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
package/src/cli-adapter-types.ts
CHANGED
|
@@ -14,6 +14,15 @@ export interface CliAdapterStatus {
|
|
|
14
14
|
activeModal?: {
|
|
15
15
|
message: string;
|
|
16
16
|
buttons: string[];
|
|
17
|
+
/**
|
|
18
|
+
* Semantic modal class, when the adapter knows it (spec/FSM path):
|
|
19
|
+
* 'approval' = tool/command/trust consent (auto-approve may fire);
|
|
20
|
+
* 'picker' = a selection menu the user opened (/model, /mode — must NOT
|
|
21
|
+
* be auto-answered); 'confirm' = a yes/no left to the user. Absent/null
|
|
22
|
+
* for adapters that don't classify modals — the auto-approve gate then
|
|
23
|
+
* falls back to its structural heuristic.
|
|
24
|
+
*/
|
|
25
|
+
kind?: 'approval' | 'picker' | 'confirm' | null;
|
|
17
26
|
} | null;
|
|
18
27
|
activeInteractivePrompt?: InteractivePrompt | null;
|
|
19
28
|
providerSessionId?: string;
|
|
@@ -106,8 +106,51 @@ function sweepExpiredRemoteIdleSessions(): void {
|
|
|
106
106
|
|
|
107
107
|
function getMeshWithCache(components: DaemonComponents, meshId: string): any | undefined {
|
|
108
108
|
const localMesh = getMesh(meshId);
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
const cachedMesh = components.router?.getCachedInlineMesh(meshId);
|
|
110
|
+
if (!localMesh) return cachedMesh;
|
|
111
|
+
if (!cachedMesh) return localMesh;
|
|
112
|
+
return mergeInlineCacheOnlyNodes(localMesh, cachedMesh);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Claim-time membership view unification (CLAIMSTALL fix).
|
|
117
|
+
*
|
|
118
|
+
* The coordinator's claim path — triggerMeshQueue → autoLaunch candidate filter
|
|
119
|
+
* and the local/remote idle-session drain — reads mesh membership through
|
|
120
|
+
* getMeshWithCache, which historically returned the local-config mesh verbatim
|
|
121
|
+
* whenever one existed. A freshly cloned worktree node is registered ONLY into the
|
|
122
|
+
* router's inline mesh cache: clone_mesh_node's `meshRecord.inline` branch calls
|
|
123
|
+
* updateInlineMeshNode, NOT addNode, so the worktree node never reaches local
|
|
124
|
+
* config (meshes.json). The config-first view therefore omits the worktree node,
|
|
125
|
+
* while send_task — which resolves membership through getMeshForCommand(preferInline)
|
|
126
|
+
* over the same inline cache — sees it. That view asymmetry is the stall: a queue
|
|
127
|
+
* task pinned to the worktree node reports `target_node_id_unmatched` (autoLaunch
|
|
128
|
+
* candidate filter / targetPinUnmatched check) and the node's idle session is
|
|
129
|
+
* dropped from the drain pool (mesh.nodes.find miss), so claim never fires and the
|
|
130
|
+
* task is stranded pending — even though nodeId matching itself is correct.
|
|
131
|
+
*
|
|
132
|
+
* Fix: union the local-config nodes with any inline-cache-ONLY nodes, so the claim
|
|
133
|
+
* view matches the command (send_task) view. Base (non-worktree) nodes present in
|
|
134
|
+
* local config stay config-authoritative — their entry is taken verbatim from
|
|
135
|
+
* localMesh, so base node claim/matching is byte-for-byte unchanged. Only nodes
|
|
136
|
+
* that exist solely in the inline cache (the cloned worktree nodes) are appended.
|
|
137
|
+
* Identity comparison uses the shared 3-form normalizer (id / nodeId / node_id),
|
|
138
|
+
* identical to every other claim-path consumer — the matching logic is untouched,
|
|
139
|
+
* only which nodes are visible.
|
|
140
|
+
*/
|
|
141
|
+
function mergeInlineCacheOnlyNodes(localMesh: any, cachedMesh: any): any {
|
|
142
|
+
const localNodes = Array.isArray(localMesh?.nodes) ? localMesh.nodes : [];
|
|
143
|
+
const cachedNodes = Array.isArray(cachedMesh?.nodes) ? cachedMesh.nodes : [];
|
|
144
|
+
if (!cachedNodes.length) return localMesh;
|
|
145
|
+
const cacheOnly = cachedNodes.filter((cachedNode: any) => {
|
|
146
|
+
const cachedId = readMeshNodeId(cachedNode);
|
|
147
|
+
// Unidentifiable cache entries can never be a claim/route target — skip them
|
|
148
|
+
// rather than appending junk that no consumer can address.
|
|
149
|
+
if (!cachedId) return false;
|
|
150
|
+
return !localNodes.some((localNode: any) => meshNodeIdMatches(localNode, cachedId));
|
|
151
|
+
});
|
|
152
|
+
if (!cacheOnly.length) return localMesh;
|
|
153
|
+
return { ...localMesh, nodes: [...localNodes, ...cacheOnly] };
|
|
111
154
|
}
|
|
112
155
|
|
|
113
156
|
const INTENTIONAL_CLEANUP_STOP_SUPPRESSION_MS = 30 * 60 * 1000;
|
|
@@ -2143,6 +2186,88 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
2143
2186
|
return { success: true, forwarded: 0 };
|
|
2144
2187
|
}
|
|
2145
2188
|
|
|
2189
|
+
// Reconstruct the metadataEvent that injectMeshSystemMessage consumes from a forwarded
|
|
2190
|
+
// (cross-machine) mesh event. The remote relay hop arrives as a flat payload, NOT the
|
|
2191
|
+
// original provider event object, so this whitelists the fields the coordinator-side
|
|
2192
|
+
// pipeline reads and re-projects them. Kept pure + exported so the relay-path field
|
|
2193
|
+
// preservation (esp. taskId) is unit-testable without driving injectMeshSystemMessage.
|
|
2194
|
+
//
|
|
2195
|
+
// IMPORTANT asymmetry: the LOCAL in-process forward path (onMeshCoordinatorEventForwarded)
|
|
2196
|
+
// passes the whole event through as metadataEvent, so every field on the event survives
|
|
2197
|
+
// there for free. This remote-only path must explicitly mirror each field it needs.
|
|
2198
|
+
export function buildRelayMetadataEvent(payload: Record<string, unknown>): Record<string, unknown> {
|
|
2199
|
+
const relayModalMessage = readNonEmptyString(payload.modalMessage);
|
|
2200
|
+
const relayModalButtons = Array.isArray(payload.modalButtons)
|
|
2201
|
+
? (payload.modalButtons as unknown[]).filter((b): b is string => typeof b === 'string' && b.trim().length > 0)
|
|
2202
|
+
: null;
|
|
2203
|
+
return {
|
|
2204
|
+
// Preserve the dispatch task id across the machine boundary. The `received` trace
|
|
2205
|
+
// stage reads payload.taskId; without mirroring it here the rebuilt metadataEvent
|
|
2206
|
+
// loses it, so injectMeshSystemMessage's traceCtx.taskId and the
|
|
2207
|
+
// updateDirectDispatchStatus(eventTaskId) call go undefined — the EvtTrace
|
|
2208
|
+
// queued/surfaced stages show task=- and the direct-dispatch ledger falls back to a
|
|
2209
|
+
// session_id match (which can flip a sibling row). The local in-process forward path
|
|
2210
|
+
// keeps event.taskId/meshActiveTaskId for free; this mirrors it for the remote relay.
|
|
2211
|
+
// Same taskId/meshActiveTaskId ordering the local unroutable trace uses.
|
|
2212
|
+
taskId: readNonEmptyString(payload.taskId) || readNonEmptyString(payload.meshActiveTaskId),
|
|
2213
|
+
targetSessionId: readNonEmptyString(payload.targetSessionId) || readNonEmptyString(payload.sessionId) || readNonEmptyString(payload.instanceId),
|
|
2214
|
+
providerType: readNonEmptyString(payload.providerType),
|
|
2215
|
+
providerSessionId: readNonEmptyString(payload.providerSessionId),
|
|
2216
|
+
// Preserve the originating coordinator SESSION id across the machine boundary so
|
|
2217
|
+
// the completion routes back to the exact coordinator session (multi-coordinator).
|
|
2218
|
+
// buildForwardPayloadFromPending spreads the worker event's metadata, so the id
|
|
2219
|
+
// arrives as payload.meshCoordinatorSessionId; the top-level targetCoordinatorSessionId
|
|
2220
|
+
// is also accepted as a fallback. injectMeshSystemMessage re-derives the routing
|
|
2221
|
+
// anchors from this. Absent → daemon-level fallback (version-skew safe).
|
|
2222
|
+
meshCoordinatorSessionId: readNonEmptyString(payload.meshCoordinatorSessionId) || readNonEmptyString(payload.targetCoordinatorSessionId),
|
|
2223
|
+
// Carry the session identity fields the worker provider event emits so the
|
|
2224
|
+
// coordinator's mirror (updateMeshOwnedSession) gets a real workspace/title/
|
|
2225
|
+
// settings. Without these the remote-relay hop reconstructs metadataEvent with
|
|
2226
|
+
// an empty workspace, and the dashboard flaps to the generic
|
|
2227
|
+
// "Terminal (Mesh Node)" title (and degrades the provider label) between live
|
|
2228
|
+
// events and the periodic get_status_metadata snapshot. The local in-process
|
|
2229
|
+
// forward path (onMeshCoordinatorEventForwarded) already preserves these; this
|
|
2230
|
+
// mirrors them for the remote-only relay path.
|
|
2231
|
+
workspace: readNonEmptyString(payload.workspace) || readNonEmptyString(payload.workspaceName),
|
|
2232
|
+
workspaceName: readNonEmptyString(payload.workspaceName) || readNonEmptyString(payload.workspace),
|
|
2233
|
+
sessionTitle: readNonEmptyString(payload.sessionTitle),
|
|
2234
|
+
sessionStatus: readNonEmptyString(payload.sessionStatus),
|
|
2235
|
+
sessionChatStatus: readNonEmptyString(payload.sessionChatStatus),
|
|
2236
|
+
providerName: readNonEmptyString(payload.providerName),
|
|
2237
|
+
...(payload.sessionSettings && typeof payload.sessionSettings === 'object' && !Array.isArray(payload.sessionSettings) ? { sessionSettings: payload.sessionSettings } : {}),
|
|
2238
|
+
finalSummary: readNonEmptyString(payload.finalSummary) || readNonEmptyString(payload.summary),
|
|
2239
|
+
// T2: carry the worker's status-snapshot last-message preview across the machine
|
|
2240
|
+
// boundary so a summary-less completion still surfaces the assistant reply in the
|
|
2241
|
+
// coordinator's inbox mirror. resolveMeshSurfacedSessionPreview reads these
|
|
2242
|
+
// (assistant-role only) when finalSummary is absent.
|
|
2243
|
+
lastMessagePreview: readNonEmptyString(payload.lastMessagePreview),
|
|
2244
|
+
lastMessageRole: readNonEmptyString(payload.lastMessageRole),
|
|
2245
|
+
...(payload.lastMessageAt !== undefined ? { lastMessageAt: payload.lastMessageAt } : {}),
|
|
2246
|
+
jobId: readNonEmptyString(payload.jobId),
|
|
2247
|
+
interactionId: readNonEmptyString(payload.interactionId),
|
|
2248
|
+
status: readNonEmptyString(payload.status),
|
|
2249
|
+
targetDaemonId: readNonEmptyString(payload.targetDaemonId),
|
|
2250
|
+
startedAt: readNonEmptyString(payload.startedAt),
|
|
2251
|
+
completedAt: readNonEmptyString(payload.completedAt),
|
|
2252
|
+
retryOfJobId: readNonEmptyString(payload.retryOfJobId),
|
|
2253
|
+
...(relayModalMessage ? { modalMessage: relayModalMessage } : {}),
|
|
2254
|
+
...(relayModalButtons && relayModalButtons.length > 0 ? { modalButtons: relayModalButtons } : {}),
|
|
2255
|
+
...(payload.result && typeof payload.result === 'object' && !Array.isArray(payload.result) ? { result: payload.result } : {}),
|
|
2256
|
+
...(payload.completionDiagnostic && typeof payload.completionDiagnostic === 'object' && !Array.isArray(payload.completionDiagnostic) ? { completionDiagnostic: payload.completionDiagnostic } : {}),
|
|
2257
|
+
...(payload.workerResult && typeof payload.workerResult === 'object' && !Array.isArray(payload.workerResult) ? { workerResult: payload.workerResult } : {}),
|
|
2258
|
+
...(payload.meshWorkerResult && typeof payload.meshWorkerResult === 'object' && !Array.isArray(payload.meshWorkerResult) ? { meshWorkerResult: payload.meshWorkerResult } : {}),
|
|
2259
|
+
...(payload.structuredResult && typeof payload.structuredResult === 'object' && !Array.isArray(payload.structuredResult) ? { structuredResult: payload.structuredResult } : {}),
|
|
2260
|
+
...(payload.timestamp !== undefined ? { timestamp: payload.timestamp } : {}),
|
|
2261
|
+
intentional: payload.intentional === true,
|
|
2262
|
+
intentionalStop: payload.intentionalStop === true,
|
|
2263
|
+
operatorCleanup: payload.operatorCleanup === true,
|
|
2264
|
+
reason: readNonEmptyString(payload.reason),
|
|
2265
|
+
stopReason: readNonEmptyString(payload.stopReason),
|
|
2266
|
+
cleanupReason: readNonEmptyString(payload.cleanupReason),
|
|
2267
|
+
source: readNonEmptyString(payload.source),
|
|
2268
|
+
};
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2146
2271
|
export function handleMeshForwardEvent(components: DaemonComponents, payload: Record<string, unknown>) {
|
|
2147
2272
|
const eventName = readNonEmptyString(payload.event);
|
|
2148
2273
|
if (!isMeshCoordinatorEvent(eventName)) {
|
|
@@ -2184,73 +2309,13 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
|
|
|
2184
2309
|
event: eventName,
|
|
2185
2310
|
});
|
|
2186
2311
|
const nodeLabel = nodeId ? `Node '${nodeId}'` : workspace ? `Agent at ${workspace}` : 'Remote agent';
|
|
2187
|
-
const relayModalMessage = readNonEmptyString(payload.modalMessage);
|
|
2188
|
-
const relayModalButtons = Array.isArray(payload.modalButtons)
|
|
2189
|
-
? (payload.modalButtons as unknown[]).filter((b): b is string => typeof b === 'string' && b.trim().length > 0)
|
|
2190
|
-
: null;
|
|
2191
2312
|
|
|
2192
2313
|
return injectMeshSystemMessage(components, {
|
|
2193
2314
|
meshId,
|
|
2194
2315
|
nodeId,
|
|
2195
2316
|
nodeLabel,
|
|
2196
2317
|
event: eventName,
|
|
2197
|
-
metadataEvent:
|
|
2198
|
-
targetSessionId: readNonEmptyString(payload.targetSessionId) || readNonEmptyString(payload.sessionId) || readNonEmptyString(payload.instanceId),
|
|
2199
|
-
providerType: readNonEmptyString(payload.providerType),
|
|
2200
|
-
providerSessionId: readNonEmptyString(payload.providerSessionId),
|
|
2201
|
-
// Preserve the originating coordinator SESSION id across the machine boundary so
|
|
2202
|
-
// the completion routes back to the exact coordinator session (multi-coordinator).
|
|
2203
|
-
// buildForwardPayloadFromPending spreads the worker event's metadata, so the id
|
|
2204
|
-
// arrives as payload.meshCoordinatorSessionId; the top-level targetCoordinatorSessionId
|
|
2205
|
-
// is also accepted as a fallback. injectMeshSystemMessage re-derives the routing
|
|
2206
|
-
// anchors from this. Absent → daemon-level fallback (version-skew safe).
|
|
2207
|
-
meshCoordinatorSessionId: readNonEmptyString(payload.meshCoordinatorSessionId) || readNonEmptyString(payload.targetCoordinatorSessionId),
|
|
2208
|
-
// Carry the session identity fields the worker provider event emits so the
|
|
2209
|
-
// coordinator's mirror (updateMeshOwnedSession) gets a real workspace/title/
|
|
2210
|
-
// settings. Without these the remote-relay hop reconstructs metadataEvent with
|
|
2211
|
-
// an empty workspace, and the dashboard flaps to the generic
|
|
2212
|
-
// "Terminal (Mesh Node)" title (and degrades the provider label) between live
|
|
2213
|
-
// events and the periodic get_status_metadata snapshot. The local in-process
|
|
2214
|
-
// forward path (onMeshCoordinatorEventForwarded) already preserves these; this
|
|
2215
|
-
// mirrors them for the remote-only relay path.
|
|
2216
|
-
workspace: readNonEmptyString(payload.workspace) || readNonEmptyString(payload.workspaceName),
|
|
2217
|
-
workspaceName: readNonEmptyString(payload.workspaceName) || readNonEmptyString(payload.workspace),
|
|
2218
|
-
sessionTitle: readNonEmptyString(payload.sessionTitle),
|
|
2219
|
-
sessionStatus: readNonEmptyString(payload.sessionStatus),
|
|
2220
|
-
sessionChatStatus: readNonEmptyString(payload.sessionChatStatus),
|
|
2221
|
-
providerName: readNonEmptyString(payload.providerName),
|
|
2222
|
-
...(payload.sessionSettings && typeof payload.sessionSettings === 'object' && !Array.isArray(payload.sessionSettings) ? { sessionSettings: payload.sessionSettings } : {}),
|
|
2223
|
-
finalSummary: readNonEmptyString(payload.finalSummary) || readNonEmptyString(payload.summary),
|
|
2224
|
-
// T2: carry the worker's status-snapshot last-message preview across the machine
|
|
2225
|
-
// boundary so a summary-less completion still surfaces the assistant reply in the
|
|
2226
|
-
// coordinator's inbox mirror. resolveMeshSurfacedSessionPreview reads these
|
|
2227
|
-
// (assistant-role only) when finalSummary is absent.
|
|
2228
|
-
lastMessagePreview: readNonEmptyString(payload.lastMessagePreview),
|
|
2229
|
-
lastMessageRole: readNonEmptyString(payload.lastMessageRole),
|
|
2230
|
-
...(payload.lastMessageAt !== undefined ? { lastMessageAt: payload.lastMessageAt } : {}),
|
|
2231
|
-
jobId: readNonEmptyString(payload.jobId),
|
|
2232
|
-
interactionId: readNonEmptyString(payload.interactionId),
|
|
2233
|
-
status: readNonEmptyString(payload.status),
|
|
2234
|
-
targetDaemonId: readNonEmptyString(payload.targetDaemonId),
|
|
2235
|
-
startedAt: readNonEmptyString(payload.startedAt),
|
|
2236
|
-
completedAt: readNonEmptyString(payload.completedAt),
|
|
2237
|
-
retryOfJobId: readNonEmptyString(payload.retryOfJobId),
|
|
2238
|
-
...(relayModalMessage ? { modalMessage: relayModalMessage } : {}),
|
|
2239
|
-
...(relayModalButtons && relayModalButtons.length > 0 ? { modalButtons: relayModalButtons } : {}),
|
|
2240
|
-
...(payload.result && typeof payload.result === 'object' && !Array.isArray(payload.result) ? { result: payload.result } : {}),
|
|
2241
|
-
...(payload.completionDiagnostic && typeof payload.completionDiagnostic === 'object' && !Array.isArray(payload.completionDiagnostic) ? { completionDiagnostic: payload.completionDiagnostic } : {}),
|
|
2242
|
-
...(payload.workerResult && typeof payload.workerResult === 'object' && !Array.isArray(payload.workerResult) ? { workerResult: payload.workerResult } : {}),
|
|
2243
|
-
...(payload.meshWorkerResult && typeof payload.meshWorkerResult === 'object' && !Array.isArray(payload.meshWorkerResult) ? { meshWorkerResult: payload.meshWorkerResult } : {}),
|
|
2244
|
-
...(payload.structuredResult && typeof payload.structuredResult === 'object' && !Array.isArray(payload.structuredResult) ? { structuredResult: payload.structuredResult } : {}),
|
|
2245
|
-
...(payload.timestamp !== undefined ? { timestamp: payload.timestamp } : {}),
|
|
2246
|
-
intentional: payload.intentional === true,
|
|
2247
|
-
intentionalStop: payload.intentionalStop === true,
|
|
2248
|
-
operatorCleanup: payload.operatorCleanup === true,
|
|
2249
|
-
reason: readNonEmptyString(payload.reason),
|
|
2250
|
-
stopReason: readNonEmptyString(payload.stopReason),
|
|
2251
|
-
cleanupReason: readNonEmptyString(payload.cleanupReason),
|
|
2252
|
-
source: readNonEmptyString(payload.source),
|
|
2253
|
-
},
|
|
2318
|
+
metadataEvent: buildRelayMetadataEvent(payload),
|
|
2254
2319
|
});
|
|
2255
2320
|
}
|
|
2256
2321
|
|
|
@@ -24,7 +24,7 @@ import { LOG } from '../logging/logger.js';
|
|
|
24
24
|
import { traceMeshEventStage, traceMeshEventDrop } from '../mesh/mesh-event-trace.js';
|
|
25
25
|
import type { ChatMessage } from '../types.js';
|
|
26
26
|
import { buildPersistedProviderEffectMessage, normalizeProviderEffects } from './control-effects.js';
|
|
27
|
-
import { formatAutoApprovalMessage, pickApprovalButton,
|
|
27
|
+
import { formatAutoApprovalMessage, pickApprovalButton, hasNegativeApprovalOption, looksLikeActiveApprovalPromptText } from './approval-utils.js';
|
|
28
28
|
import { getCliScriptCommand, parseCliScriptResult } from './cli-script-results.js';
|
|
29
29
|
import { mergeProviderPatchState, resolveProviderStateSurface } from './provider-patch-state.js';
|
|
30
30
|
import { normalizeProviderSessionId } from './provider-session-id.js';
|
|
@@ -1622,10 +1622,34 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1622
1622
|
if (!modal || buttons.length === 0) {
|
|
1623
1623
|
return autoApproveActive;
|
|
1624
1624
|
}
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1625
|
+
// Picker/confirm exclusion (provider-common). A /model or /mode picker is
|
|
1626
|
+
// surfaced with status=waiting_approval so the dashboard shows it, but it
|
|
1627
|
+
// has no "correct" answer to auto-pick — blindly selecting the first
|
|
1628
|
+
// option silently switches the model (the "always Opus, before I even
|
|
1629
|
+
// choose" bug). Two independent gates, BOTH must pass to fire:
|
|
1630
|
+
//
|
|
1631
|
+
// (1) modal_kind — the spec/FSM tells us this is an 'approval' modal,
|
|
1632
|
+
// not a 'picker'/'confirm'. A modal whose kind is unknown (legacy
|
|
1633
|
+
// adapter, or a spec that predates modal_kind) reads as 'approval'
|
|
1634
|
+
// so genuine approvals keep auto-approving; only an explicit
|
|
1635
|
+
// 'picker'/'confirm' is excluded here.
|
|
1636
|
+
// (2) structural anchor — a real approval offers an affirmative AND a
|
|
1637
|
+
// decline option (pickApprovalButton finds a positive that isn't a
|
|
1638
|
+
// decline, and hasNegativeApprovalOption confirms a No/Cancel/Deny
|
|
1639
|
+
// is present). A model picker ("1. Default 2. Opus 3. Sonnet")
|
|
1640
|
+
// has no decline, so even an un-migrated picker is caught here.
|
|
1641
|
+
//
|
|
1642
|
+
// Mirrors the SDK v1 detect-status approval heuristic (detect-status.ts).
|
|
1643
|
+
const modalKind = typeof modal?.kind === 'string' ? modal.kind : 'approval';
|
|
1644
|
+
if (modalKind !== 'approval') {
|
|
1645
|
+
// Picker/confirm — leave it for the user; keep the modal surfaced.
|
|
1646
|
+
return autoApproveActive;
|
|
1647
|
+
}
|
|
1648
|
+
const { index: buttonIndex, label: buttonLabel } = pickApprovalButton(buttons, this.provider);
|
|
1649
|
+
if (buttonIndex < 0 || !hasNegativeApprovalOption(buttons)) {
|
|
1650
|
+
// No affirmative matched, or no decline option present (→ not a real
|
|
1651
|
+
// consent prompt, e.g. a picker that slipped past the kind gate).
|
|
1652
|
+
// Surface the modal so the user decides; never pick blindly.
|
|
1629
1653
|
return autoApproveActive;
|
|
1630
1654
|
}
|
|
1631
1655
|
// Modal *identity* signature — the question/button set only, NO volatile
|
|
@@ -75,7 +75,7 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
75
75
|
};
|
|
76
76
|
private lastEvent: DashboardEvent | null = null;
|
|
77
77
|
private latestState: { id: string; label: string; title: string | null; status: 'idle' | 'generating' | 'approval' } | null = null;
|
|
78
|
-
private latestModal: { title: string | null; buttons: { index: number; label: string }[] } | null = null;
|
|
78
|
+
private latestModal: { title: string | null; buttons: { index: number; label: string }[]; kind?: 'approval' | 'picker' | 'confirm' | null } | null = null;
|
|
79
79
|
private statusCallback: (() => void) | null = null;
|
|
80
80
|
private ptyDataCallback: ((data: string) => void) | null = null;
|
|
81
81
|
private partialResponse = '';
|
|
@@ -193,8 +193,10 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
193
193
|
messages: [],
|
|
194
194
|
// Surface buttons when we have them; an approval state with no parsed
|
|
195
195
|
// modal this frame still stays waiting_approval (no activeModal yet).
|
|
196
|
+
// `kind` carries the semantic modal class through to the auto-approve
|
|
197
|
+
// gate so a /model picker (kind='picker') is never auto-answered.
|
|
196
198
|
activeModal: modal
|
|
197
|
-
? { message: modal.title ?? state.label, buttons: modal.buttons.map(b => b.label) }
|
|
199
|
+
? { message: modal.title ?? state.label, buttons: modal.buttons.map(b => b.label), kind: modal.kind ?? null }
|
|
198
200
|
: null,
|
|
199
201
|
activeInteractivePrompt: this.activeInteractivePrompt,
|
|
200
202
|
...sessionFields,
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
import { evaluateFsm, type FsmClock, type TransitionEval, type FsmEvaluation } from './fsm-evaluator.js';
|
|
33
33
|
import {
|
|
34
34
|
type CliSpecV4, type FsmState, type FsmTransition,
|
|
35
|
-
initialState, stateById, statusForState, outgoingTransitions,
|
|
35
|
+
initialState, stateById, statusForState, modalKindForState, outgoingTransitions,
|
|
36
36
|
} from './fsm-types.js';
|
|
37
37
|
import { loadFsmSpec } from './fsm-loader.js';
|
|
38
38
|
import { applyPreLaunchTrust } from './pre-launch-trust.js';
|
|
@@ -44,7 +44,7 @@ import { LOG } from '../../logging/logger.js';
|
|
|
44
44
|
export type DashboardEvent =
|
|
45
45
|
| { kind: 'pty_data'; chunk: string }
|
|
46
46
|
| { kind: 'state_changed'; state: { id: string; label: string; title: string | null; status: 'idle' | 'generating' | 'approval' };
|
|
47
|
-
modal: { title: string | null; buttons: { index: number; label: string }[] } | null;
|
|
47
|
+
modal: { title: string | null; buttons: { index: number; label: string }[]; kind: 'approval' | 'picker' | 'confirm' | null } | null;
|
|
48
48
|
controls: { id: string; label: string; action_type: string }[] }
|
|
49
49
|
| { kind: 'notification'; id: string; title: string; body: string }
|
|
50
50
|
| { kind: 'delegate'; id: string; task: string }
|
|
@@ -694,7 +694,12 @@ export class FsmDriver implements ISpecDriver {
|
|
|
694
694
|
this.emit({
|
|
695
695
|
kind: 'state_changed',
|
|
696
696
|
state: next.state,
|
|
697
|
-
|
|
697
|
+
// kind is the SEMANTIC modal class (approval vs picker/confirm)
|
|
698
|
+
// derived from the FSM state, NOT from the parsed buttons — the
|
|
699
|
+
// status field already collapsed it to 'approval' so the modal is
|
|
700
|
+
// surfaced. The auto-approve worker needs the distinction back to
|
|
701
|
+
// avoid answering a /model picker on the user's behalf.
|
|
702
|
+
modal: next.modal ? { title: next.modal.title, buttons: next.modal.buttons.map(b => ({ index: b.index, label: b.label })), kind: modalKindForState(state) } : null,
|
|
698
703
|
controls: next.controls.map(c => ({ id: c.id, label: c.label, action_type: c.actionType })),
|
|
699
704
|
});
|
|
700
705
|
this.fireNotifications(state.id, title);
|
|
@@ -88,6 +88,27 @@ export interface FsmState {
|
|
|
88
88
|
/** Modal states (approval/picker) expose modal buttons in the UI and are
|
|
89
89
|
* treated as "interesting" — the dashboard surfaces them distinctly. */
|
|
90
90
|
modal?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* For a modal state, what KIND of modal it is — the semantic distinction the
|
|
93
|
+
* status field (always 'approval' for any modal, so the dashboard surfaces it)
|
|
94
|
+
* deliberately loses. The auto-approve worker uses this to decide whether it
|
|
95
|
+
* may answer the modal on the user's behalf:
|
|
96
|
+
*
|
|
97
|
+
* - 'approval' — a tool/command/trust consent prompt ("Allow Bash?",
|
|
98
|
+
* "Trust this folder?"). Auto-approve MAY fire (a background mesh worker
|
|
99
|
+
* should not stall on these).
|
|
100
|
+
* - 'picker' — a selection menu the user opened (/model, /mode, …). There
|
|
101
|
+
* is no "correct" answer to auto-pick; blindly selecting the first option
|
|
102
|
+
* silently changes the model/mode. Auto-approve must NOT fire — the user
|
|
103
|
+
* chooses.
|
|
104
|
+
* - 'confirm' — a non-consent yes/no the user must decide. Left to the user.
|
|
105
|
+
*
|
|
106
|
+
* Defaults to 'approval' for a modal state that omits it (preserves the
|
|
107
|
+
* pre-existing "auto-approve any modal" behaviour for un-migrated specs;
|
|
108
|
+
* picker/confirm states declare their kind explicitly). Non-modal states
|
|
109
|
+
* have no modal_kind.
|
|
110
|
+
*/
|
|
111
|
+
modal_kind?: 'approval' | 'picker' | 'confirm';
|
|
91
112
|
/** Status this state maps to for the dashboard/cli-adapter status field.
|
|
92
113
|
* One of: idle | generating | approval. Defaults: modal→approval,
|
|
93
114
|
* initial→idle, id==='busy'→generating, else idle. Explicit wins. */
|
|
@@ -236,3 +257,17 @@ export function statusForState(state: FsmState): 'idle' | 'generating' | 'approv
|
|
|
236
257
|
if (state.id === 'busy' || state.id === 'generating') return 'generating';
|
|
237
258
|
return 'idle';
|
|
238
259
|
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The modal kind for a state, or null when the state is not modal. A modal state
|
|
263
|
+
* that omits `modal_kind` defaults to 'approval' so the established
|
|
264
|
+
* auto-approve-any-modal behaviour is preserved for specs that have not yet
|
|
265
|
+
* declared a kind; picker/confirm states must opt out by declaring their kind.
|
|
266
|
+
* This is the value the cli-adapter carries on `activeModal.kind` and the
|
|
267
|
+
* auto-approve gate reads — see cli-provider-instance.maybeAutoApproveStatus.
|
|
268
|
+
*/
|
|
269
|
+
export function modalKindForState(state: FsmState): 'approval' | 'picker' | 'confirm' | null {
|
|
270
|
+
if (state.modal_kind) return state.modal_kind;
|
|
271
|
+
if (state.modal) return 'approval';
|
|
272
|
+
return null;
|
|
273
|
+
}
|