@adhdev/daemon-core 0.9.82-rc.252 → 0.9.82-rc.254
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/index.js +159 -121
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +159 -121
- package/dist/index.mjs.map +1 -1
- package/dist/providers/cli-provider-instance.d.ts +0 -3
- package/dist/providers/spec/fsm-driver.d.ts +1 -0
- package/package.json +1 -1
- package/src/commands/router.ts +34 -1
- package/src/mesh/mesh-events-coordinator.ts +94 -35
- package/src/mesh/mesh-events-pending.ts +14 -6
- package/src/mesh/mesh-work-queue.ts +60 -2
- package/src/providers/cli-provider-instance.ts +22 -101
- package/src/providers/spec/cli-adapter.ts +12 -8
- package/src/providers/spec/fsm-driver.ts +8 -3
|
@@ -56,7 +56,6 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
56
56
|
private runtimeMessages;
|
|
57
57
|
private lastPersistedHistoryMessages;
|
|
58
58
|
private lastAcknowledgedUserInputAt;
|
|
59
|
-
private externalBusyIdleFingerprint;
|
|
60
59
|
private lastNativeSourceCanonicalCheckAt;
|
|
61
60
|
private lastNativeSourceCanonicalCacheKey;
|
|
62
61
|
private cachedSqliteDb;
|
|
@@ -133,8 +132,6 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
133
132
|
private readExternalCompletionMessages;
|
|
134
133
|
private completionFinalAssistantEvidence;
|
|
135
134
|
private completionFinalSummary;
|
|
136
|
-
private externalNativeFinalFingerprint;
|
|
137
|
-
private getExternalNativeFinalReconciliation;
|
|
138
135
|
private buildCompletedFinalizationDiagnostic;
|
|
139
136
|
private hasAdapterPendingResponse;
|
|
140
137
|
private shouldSuppressStaleParsedBusyStatus;
|
package/package.json
CHANGED
package/src/commands/router.ts
CHANGED
|
@@ -627,10 +627,12 @@ function reconcileInlineMeshCache(cached: any, incoming: any): any {
|
|
|
627
627
|
if (nodeId) cachedById.set(nodeId, node);
|
|
628
628
|
}
|
|
629
629
|
|
|
630
|
+
const mergedIncomingIds = new Set<string>();
|
|
630
631
|
const nodes = incomingNodes.map((incomingNode: any) => {
|
|
631
632
|
const nodeId = readInlineMeshNodeId(incomingNode);
|
|
632
633
|
const cachedNode = nodeId ? cachedById.get(nodeId) : undefined;
|
|
633
634
|
if (!cachedNode && preserveCachedMembership) return null;
|
|
635
|
+
if (nodeId) mergedIncomingIds.add(nodeId);
|
|
634
636
|
if (!cachedNode) return incomingNode;
|
|
635
637
|
if (hasInlineMeshTransientNodeState(incomingNode)) {
|
|
636
638
|
return { ...cachedNode, ...incomingNode };
|
|
@@ -638,6 +640,21 @@ function reconcileInlineMeshCache(cached: any, incoming: any): any {
|
|
|
638
640
|
return { ...stripInlineMeshTransientNodeState(cachedNode), ...incomingNode };
|
|
639
641
|
}).filter(Boolean);
|
|
640
642
|
|
|
643
|
+
// When the cached membership is authoritative (newer than the incoming
|
|
644
|
+
// snapshot), nodes that exist only in the cache must survive reconciliation.
|
|
645
|
+
// A freshly cloned worktree node lives only in the coordinator's cache until
|
|
646
|
+
// the next snapshot catches up; iterating incomingNodes alone would silently
|
|
647
|
+
// drop it, making the node invisible to get_mesh / membership reads even
|
|
648
|
+
// though worktree_bootstrap_complete already fired.
|
|
649
|
+
if (preserveCachedMembership) {
|
|
650
|
+
for (const cachedNode of cachedNodes) {
|
|
651
|
+
const nodeId = readInlineMeshNodeId(cachedNode);
|
|
652
|
+
if (nodeId && !mergedIncomingIds.has(nodeId)) {
|
|
653
|
+
nodes.push(cachedNode);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
641
658
|
return {
|
|
642
659
|
...cached,
|
|
643
660
|
...incoming,
|
|
@@ -6005,7 +6022,16 @@ export class DaemonCommandRouter {
|
|
|
6005
6022
|
if (ownerFailure) return ownerFailure;
|
|
6006
6023
|
|
|
6007
6024
|
try {
|
|
6008
|
-
|
|
6025
|
+
// Resolve with preferInline so the clone writes the new node into the
|
|
6026
|
+
// same representation that get_mesh reads back. The MCP coordinator
|
|
6027
|
+
// passes inlineMesh on every mesh command, so when it owns an inline
|
|
6028
|
+
// mesh the membership read path (get_mesh, preferInline: true) returns
|
|
6029
|
+
// the inline cache. Without preferInline here, clone could resolve to a
|
|
6030
|
+
// local-config mesh and write the node only to config — leaving the
|
|
6031
|
+
// inline cache (and therefore get_mesh / refreshMeshFromDaemon) without
|
|
6032
|
+
// the node, so the new worktree node is never visible in live mesh
|
|
6033
|
+
// membership even though worktree_bootstrap_complete fires.
|
|
6034
|
+
const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
|
|
6009
6035
|
const mesh = meshRecord?.mesh;
|
|
6010
6036
|
if (!mesh) return { success: false, error: 'Mesh not found' };
|
|
6011
6037
|
|
|
@@ -6064,6 +6090,13 @@ export class DaemonCommandRouter {
|
|
|
6064
6090
|
policy: { ...(sourceNode.policy || {}) },
|
|
6065
6091
|
});
|
|
6066
6092
|
if (!node) return { success: false, error: 'Failed to register worktree node' };
|
|
6093
|
+
// Also reconcile the freshly-registered node into any warmed inline
|
|
6094
|
+
// cache for this mesh. get_mesh (preferInline: true) reads the inline
|
|
6095
|
+
// cache first when one exists; if we only wrote to local config the
|
|
6096
|
+
// node would be invisible to membership reads. updateInlineMeshNode is
|
|
6097
|
+
// a no-op when no inline cache is present.
|
|
6098
|
+
const inlineForReconcile = this.getCachedInlineMesh(meshId);
|
|
6099
|
+
if (inlineForReconcile) this.updateInlineMeshNode(meshId, inlineForReconcile, node);
|
|
6067
6100
|
this.invalidateAggregateMeshStatus(meshId);
|
|
6068
6101
|
}
|
|
6069
6102
|
|
|
@@ -438,7 +438,21 @@ function nodeHasActiveAssignment(meshId: string, nodeId: string): boolean {
|
|
|
438
438
|
}
|
|
439
439
|
|
|
440
440
|
function sessionHasActiveAssignment(meshId: string, sessionId: string): boolean {
|
|
441
|
-
|
|
441
|
+
if (getQueue(meshId, { status: ['assigned'] as any }).some(task => task.assignedSessionId === sessionId)) {
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
// Direct dispatches (mesh_send_task) are tracked in mesh_direct_dispatches, not the
|
|
445
|
+
// work queue. A session completing a still-active direct dispatch IS an active
|
|
446
|
+
// assignment — without this, findRecentTerminalLedgerEvidence dedup wrongly suppresses
|
|
447
|
+
// the canonical agent:generating_completed for direct-dispatch tasks (validation/general),
|
|
448
|
+
// so the coordinator polling get_pending_mesh_events never observes task_completed and the
|
|
449
|
+
// session goes silently idle. This check runs before markSessionTerminal marks the
|
|
450
|
+
// dispatch terminal, so the in-flight dispatch is still observable here.
|
|
451
|
+
try {
|
|
452
|
+
if (getActiveDirectDispatches(meshId).some(d => d.sessionId === sessionId)) return true;
|
|
453
|
+
if (hasUnterminalDirectDispatchLedgerEntry(meshId, sessionId)) return true;
|
|
454
|
+
} catch { /* best-effort — fall through to false */ }
|
|
455
|
+
return false;
|
|
442
456
|
}
|
|
443
457
|
|
|
444
458
|
function liveSessionCountForNode(components: DaemonComponents, meshId: string, nodeId: string): number {
|
|
@@ -927,6 +941,27 @@ export function isMeshCoordinatorEvent(eventName: unknown): eventName is string
|
|
|
927
941
|
return typeof eventName === 'string' && MESH_COORDINATOR_EVENTS.has(eventName);
|
|
928
942
|
}
|
|
929
943
|
|
|
944
|
+
// Terminal events that the coordinator is actively blocked waiting on. When the
|
|
945
|
+
// coordinator CLI session dispatches a task (e.g. mesh_send_task) it stays in
|
|
946
|
+
// `generating` until the result arrives — but a generating coordinator queues
|
|
947
|
+
// incoming send_message calls into its adapter's pendingOutboundQueue, which is
|
|
948
|
+
// only flushed on the coordinator's OWN idle transition. That transition can't
|
|
949
|
+
// happen until it receives this very event → deadlock. We force-inject these so
|
|
950
|
+
// they bypass the busy send-guard and land in the PTY while generating.
|
|
951
|
+
const MESH_FORCE_INJECT_EVENTS = new Set([
|
|
952
|
+
'agent:generating_completed',
|
|
953
|
+
'agent:stopped',
|
|
954
|
+
'agent:waiting_approval',
|
|
955
|
+
'refine:completed',
|
|
956
|
+
'refine:failed',
|
|
957
|
+
'worktree_bootstrap_complete',
|
|
958
|
+
'worktree_bootstrap_failed',
|
|
959
|
+
]);
|
|
960
|
+
|
|
961
|
+
function shouldForceInjectMeshEvent(eventName: unknown): boolean {
|
|
962
|
+
return typeof eventName === 'string' && MESH_FORCE_INJECT_EVENTS.has(eventName);
|
|
963
|
+
}
|
|
964
|
+
|
|
930
965
|
function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
931
966
|
meshId: string;
|
|
932
967
|
sourceInstanceId?: string;
|
|
@@ -1362,10 +1397,14 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
1362
1397
|
LOG.info('MeshEvents', `Queued ${args.event} for MCP coordinator (mesh ${args.meshId})`);
|
|
1363
1398
|
}
|
|
1364
1399
|
|
|
1400
|
+
const forceInject = shouldForceInjectMeshEvent(args.event);
|
|
1365
1401
|
for (const coord of coordinatorInstances) {
|
|
1366
1402
|
const coordState = coord.getState();
|
|
1367
|
-
LOG.info('MeshEvents', `Forwarding mesh event to coordinator ${coordState.instanceId}`);
|
|
1368
|
-
coord.onEvent('send_message', {
|
|
1403
|
+
LOG.info('MeshEvents', `Forwarding mesh event to coordinator ${coordState.instanceId}${forceInject ? ' (force)' : ''}`);
|
|
1404
|
+
coord.onEvent('send_message', {
|
|
1405
|
+
input: { text: messageText, textFallback: messageText },
|
|
1406
|
+
...(forceInject ? { force: true } : {}),
|
|
1407
|
+
});
|
|
1369
1408
|
}
|
|
1370
1409
|
return { success: true, forwarded: coordinatorInstances.length };
|
|
1371
1410
|
}
|
|
@@ -1424,6 +1463,58 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
|
|
|
1424
1463
|
|
|
1425
1464
|
export function setupMeshEventForwarding(components: DaemonComponents) {
|
|
1426
1465
|
components.instanceManager.onEvent((event) => {
|
|
1466
|
+
// --- Coordinator idle auto-flush ---
|
|
1467
|
+
// When a coordinator session becomes idle, flush any pending coordinator events
|
|
1468
|
+
// that accumulated while it was generating. This runs before the delegate routing
|
|
1469
|
+
// below so that coordinator-own idle transitions are handled first.
|
|
1470
|
+
// Exception: a coordinator that is itself a direct-dispatch target still needs
|
|
1471
|
+
// to go through delegate routing so that the dispatching coordinator receives a
|
|
1472
|
+
// pendingCoordinatorEvents entry for the completion.
|
|
1473
|
+
if (event.event === 'agent:ready' || event.event === 'agent:generating_completed') {
|
|
1474
|
+
const flushInstanceId = readNonEmptyString(event.instanceId);
|
|
1475
|
+
if (flushInstanceId) {
|
|
1476
|
+
const flushSource = components.instanceManager.getInstance(flushInstanceId);
|
|
1477
|
+
if (flushSource && flushSource.category === 'cli') {
|
|
1478
|
+
const flushState = flushSource.getState();
|
|
1479
|
+
const flushSettings = flushState.settings && typeof flushState.settings === 'object' ? flushState.settings as Record<string, unknown> : {};
|
|
1480
|
+
const coordinatorMeshId = readNonEmptyString(flushSettings.meshCoordinatorFor);
|
|
1481
|
+
if (coordinatorMeshId) {
|
|
1482
|
+
const status = readNonEmptyString(flushState.status).toLowerCase();
|
|
1483
|
+
if (status === 'idle') {
|
|
1484
|
+
try {
|
|
1485
|
+
const localDaemonId = readNonEmptyString(loadConfig().machineId) || undefined;
|
|
1486
|
+
const pendingEvents = drainPendingMeshCoordinatorEvents(coordinatorMeshId, localDaemonId);
|
|
1487
|
+
if (pendingEvents.length > 0) {
|
|
1488
|
+
LOG.info('MeshEvents', `Auto-flushing ${pendingEvents.length} pending coordinator event(s) for mesh ${coordinatorMeshId} on coordinator idle`);
|
|
1489
|
+
for (const pending of pendingEvents) {
|
|
1490
|
+
if (!pending.coordinatorMessage) continue;
|
|
1491
|
+
const forcePending = shouldForceInjectMeshEvent(pending.event);
|
|
1492
|
+
flushSource.onEvent('send_message', {
|
|
1493
|
+
input: { text: pending.coordinatorMessage, textFallback: pending.coordinatorMessage },
|
|
1494
|
+
...(forcePending ? { force: true } : {}),
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
} catch (e: any) {
|
|
1499
|
+
LOG.warn('MeshEvents', `Failed to auto-flush pending coordinator events: ${e?.message || e}`);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
// Skip delegate routing unless this coordinator session is itself
|
|
1503
|
+
// a direct-dispatch target — in that case fall through so the
|
|
1504
|
+
// dispatching coordinator gets a pendingCoordinatorEvents entry.
|
|
1505
|
+
let hasDirectDispatch = false;
|
|
1506
|
+
try {
|
|
1507
|
+
hasDirectDispatch =
|
|
1508
|
+
getActiveDirectDispatches(coordinatorMeshId).some(d => d.sessionId === flushInstanceId)
|
|
1509
|
+
|| hasUnterminalDirectDispatchLedgerEntry(coordinatorMeshId, flushInstanceId);
|
|
1510
|
+
} catch { /* best-effort */ }
|
|
1511
|
+
if (!hasDirectDispatch) return;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// --- Delegate event routing ---
|
|
1427
1518
|
if (!isMeshCoordinatorEvent(event.event)) return;
|
|
1428
1519
|
|
|
1429
1520
|
const instanceId = readNonEmptyString(event.instanceId);
|
|
@@ -1475,36 +1566,4 @@ export function setupMeshEventForwarding(components: DaemonComponents) {
|
|
|
1475
1566
|
metadataEvent: event,
|
|
1476
1567
|
});
|
|
1477
1568
|
});
|
|
1478
|
-
|
|
1479
|
-
// Auto-flush pending coordinator events when a coordinator session becomes idle.
|
|
1480
|
-
components.instanceManager.onEvent((event) => {
|
|
1481
|
-
if (event.event !== 'agent:ready' && event.event !== 'agent:generating_completed') return;
|
|
1482
|
-
|
|
1483
|
-
const instanceId = readNonEmptyString(event.instanceId);
|
|
1484
|
-
if (!instanceId) return;
|
|
1485
|
-
|
|
1486
|
-
const sourceInstance = components.instanceManager.getInstance(instanceId);
|
|
1487
|
-
if (!sourceInstance || sourceInstance.category !== 'cli') return;
|
|
1488
|
-
const state = sourceInstance.getState();
|
|
1489
|
-
const settings = state.settings && typeof state.settings === 'object' ? state.settings as Record<string, unknown> : {};
|
|
1490
|
-
|
|
1491
|
-
const coordinatorMeshId = readNonEmptyString(settings.meshCoordinatorFor);
|
|
1492
|
-
if (!coordinatorMeshId) return;
|
|
1493
|
-
|
|
1494
|
-
const status = readNonEmptyString(state.status).toLowerCase();
|
|
1495
|
-
if (status !== 'idle') return;
|
|
1496
|
-
|
|
1497
|
-
try {
|
|
1498
|
-
const localDaemonId = readNonEmptyString(loadConfig().machineId) || undefined;
|
|
1499
|
-
const pendingEvents = drainPendingMeshCoordinatorEvents(coordinatorMeshId, localDaemonId);
|
|
1500
|
-
if (pendingEvents.length === 0) return;
|
|
1501
|
-
LOG.info('MeshEvents', `Auto-flushing ${pendingEvents.length} pending coordinator event(s) for mesh ${coordinatorMeshId} on coordinator idle`);
|
|
1502
|
-
for (const pending of pendingEvents) {
|
|
1503
|
-
if (!pending.coordinatorMessage) continue;
|
|
1504
|
-
sourceInstance.onEvent('send_message', { input: { text: pending.coordinatorMessage, textFallback: pending.coordinatorMessage } });
|
|
1505
|
-
}
|
|
1506
|
-
} catch (e: any) {
|
|
1507
|
-
LOG.warn('MeshEvents', `Failed to auto-flush pending coordinator events: ${e?.message || e}`);
|
|
1508
|
-
}
|
|
1509
|
-
});
|
|
1510
1569
|
}
|
|
@@ -185,12 +185,20 @@ function refineTerminalEventFromLedger(meshId: string, pending: readonly Pending
|
|
|
185
185
|
|
|
186
186
|
function reconcilePendingMeshCoordinatorEvents(meshId: string, events: PendingMeshCoordinatorEvent[]): PendingMeshCoordinatorEvent[] {
|
|
187
187
|
const backfilled = refineTerminalEventFromLedger(meshId, events);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
188
|
+
// A refine:accepted event is a provisional "job accepted, result to follow" signal.
|
|
189
|
+
// Once its terminal (completed/failed) counterpart for the same jobId exists — whether
|
|
190
|
+
// already direct-queued into the pending store OR backfilled from the ledger here — the
|
|
191
|
+
// accepted is superseded and is dropped so the coordinator isn't shown stale duplicate
|
|
192
|
+
// noise alongside the terminal outcome.
|
|
193
|
+
const terminalJobIds = new Set(
|
|
194
|
+
[...events.filter(event => REFINE_TERMINAL_EVENTS.has(event.event)), ...backfilled]
|
|
195
|
+
.map(event => readRefineJobId(event))
|
|
196
|
+
.filter(Boolean),
|
|
197
|
+
);
|
|
198
|
+
const reconciled = terminalJobIds.size === 0
|
|
199
|
+
? events
|
|
200
|
+
: events.filter(event => !(event.event === 'refine:accepted' && terminalJobIds.has(readRefineJobId(event))));
|
|
201
|
+
return backfilled.length === 0 ? reconciled : [...reconciled, ...backfilled];
|
|
194
202
|
}
|
|
195
203
|
|
|
196
204
|
const MAX_PENDING_EVENTS_BYTES = 100 * 1024; // 100 KB — keep the pending file small
|
|
@@ -22,7 +22,6 @@ export interface MeshTaskModeValidationResult {
|
|
|
22
22
|
|
|
23
23
|
const LIVE_DEBUG_READONLY_FORBIDDEN: Array<{ label: string; pattern: RegExp }> = [
|
|
24
24
|
{ label: 'source_edit', pattern: /\b(edit|modify|patch|apply\s+patch|write\s+(?:to\s+)?(?:file|source)|overwrite|delete\s+file|remove\s+file|create\s+file|touch\s+file)\b/i },
|
|
25
|
-
{ label: 'git_mutation', pattern: /\b(?:git\s+(?:add|commit|push|reset|rebase|clean|checkout|switch|merge|tag|restore|rm|mv|stash|worktree\s+(?:add|remove|move))|push\b)/i },
|
|
26
25
|
{ label: 'checkpoint', pattern: /\b(checkpoint|mesh_checkpoint)\b/i },
|
|
27
26
|
{ label: 'deploy_or_version_bump', pattern: /\b(deploy|wrangler\s+deploy|version[-\s]?bump|npm\s+version|release|npm\s+publish|yarn\s+publish|pnpm\s+publish)\b/i },
|
|
28
27
|
{ label: 'destructive_shell', pattern: /\b(rm\s+-rf|mv\s+\S+\s+\S+|truncate\s|tee\s+\S+|sed\s+-i|shred\b)\b/i },
|
|
@@ -30,6 +29,61 @@ const LIVE_DEBUG_READONLY_FORBIDDEN: Array<{ label: string; pattern: RegExp }> =
|
|
|
30
29
|
{ label: 'container_mutation', pattern: /\b(docker\s+(?:build|run|exec|push|tag|rmi|rm|create|start|stop|kill)|kubectl\s+(?:apply|delete|patch|replace|create|scale))\b/i },
|
|
31
30
|
];
|
|
32
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Git subcommands that mutate the working tree, index, refs, or remote.
|
|
34
|
+
* `stash` and `checkout` are intentionally absent here: they have read-only
|
|
35
|
+
* variants (`git stash list`/`show`, `git checkout-index`) and are classified
|
|
36
|
+
* token-by-token in {@link detectGitMutation} rather than by bare keyword.
|
|
37
|
+
*/
|
|
38
|
+
const GIT_MUTATION_SUBCOMMANDS = new Set([
|
|
39
|
+
'add', 'commit', 'push', 'reset', 'rebase', 'clean', 'switch', 'merge',
|
|
40
|
+
'tag', 'restore', 'rm', 'mv', 'cherry-pick', 'revert', 'pull', 'fetch',
|
|
41
|
+
'am', 'apply', 'gc', 'prune',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read-only `git stash` variants. Any other `git stash <x>` (pop/apply/drop/
|
|
46
|
+
* push/save/clear, or bare `git stash` which defaults to push) is a mutation.
|
|
47
|
+
*/
|
|
48
|
+
const GIT_STASH_READONLY_SUBCOMMANDS = new Set(['list', 'show']);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Detects a true git mutation in free-text task message, token-aware so that
|
|
52
|
+
* read-only diagnostics (`git stash list`, `git stash show --stat`,
|
|
53
|
+
* `git checkout-index`, `git status`, `git diff`, `git log`, ...) are allowed.
|
|
54
|
+
* Returns true only when a genuine mutating git invocation is present.
|
|
55
|
+
*/
|
|
56
|
+
function detectGitMutation(message: string): boolean {
|
|
57
|
+
const re = /\bgit\s+([a-z][a-z0-9-]*)/gi;
|
|
58
|
+
let match: RegExpExecArray | null;
|
|
59
|
+
while ((match = re.exec(message)) !== null) {
|
|
60
|
+
const sub = match[1].toLowerCase();
|
|
61
|
+
if (GIT_MUTATION_SUBCOMMANDS.has(sub)) return true;
|
|
62
|
+
if (sub === 'stash') {
|
|
63
|
+
// Token following `git stash`; read-only only for list/show.
|
|
64
|
+
const after = message.slice(re.lastIndex).match(/^\s+([a-z][a-z0-9-]*)/i);
|
|
65
|
+
const next = after ? after[1].toLowerCase() : '';
|
|
66
|
+
if (!GIT_STASH_READONLY_SUBCOMMANDS.has(next)) return true; // bare stash = push, or pop/apply/drop/...
|
|
67
|
+
} else if (sub === 'checkout') {
|
|
68
|
+
// `git checkout <ref/path>` mutates; `git checkout-index` is matched
|
|
69
|
+
// as its own token by the regex (sub === 'checkout-index') and is read-only.
|
|
70
|
+
return true;
|
|
71
|
+
} else if (sub === 'submodule') {
|
|
72
|
+
// `git submodule update` mutates; `git submodule status` is read-only.
|
|
73
|
+
const after = message.slice(re.lastIndex).match(/^\s+([a-z][a-z0-9-]*)/i);
|
|
74
|
+
const next = after ? after[1].toLowerCase() : '';
|
|
75
|
+
if (next === 'update' || next === 'add' || next === 'sync' || next === 'deinit') return true;
|
|
76
|
+
} else if (sub === 'worktree') {
|
|
77
|
+
const after = message.slice(re.lastIndex).match(/^\s+([a-z][a-z0-9-]*)/i);
|
|
78
|
+
const next = after ? after[1].toLowerCase() : '';
|
|
79
|
+
if (next === 'add' || next === 'remove' || next === 'move' || next === 'prune') return true;
|
|
80
|
+
}
|
|
81
|
+
// checkout-index, stash-with-no-next-already-handled, status/diff/log/show/
|
|
82
|
+
// rev-parse/branch/submodule status fall through as read-only.
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
33
87
|
export function normalizeMeshTaskMode(value: unknown): MeshTaskMode | undefined {
|
|
34
88
|
if (typeof value !== 'string') return undefined;
|
|
35
89
|
const normalized = value.trim() as MeshTaskMode;
|
|
@@ -44,9 +98,13 @@ export function validateMeshTaskModeRequest(mode: unknown, message: string): Mes
|
|
|
44
98
|
if (taskMode !== 'live_debug_readonly') {
|
|
45
99
|
return { valid: true, taskMode, violations: [] };
|
|
46
100
|
}
|
|
101
|
+
const text = message || '';
|
|
47
102
|
const violations = LIVE_DEBUG_READONLY_FORBIDDEN
|
|
48
|
-
.filter(rule => rule.pattern.test(
|
|
103
|
+
.filter(rule => rule.pattern.test(text))
|
|
49
104
|
.map(rule => rule.label);
|
|
105
|
+
if (detectGitMutation(text)) {
|
|
106
|
+
violations.push('git_mutation');
|
|
107
|
+
}
|
|
50
108
|
return {
|
|
51
109
|
valid: violations.length === 0,
|
|
52
110
|
taskMode,
|
|
@@ -73,12 +73,6 @@ type CompletionFinalAssistantEvidence = {
|
|
|
73
73
|
source: 'parsed' | 'external-native' | 'unavailable';
|
|
74
74
|
};
|
|
75
75
|
|
|
76
|
-
type ExternalNativeFinalReconciliation = {
|
|
77
|
-
fingerprint: string;
|
|
78
|
-
finalSummary: string;
|
|
79
|
-
evidence: CompletionFinalAssistantEvidence;
|
|
80
|
-
};
|
|
81
|
-
|
|
82
76
|
type ExternalTranscriptProbe = {
|
|
83
77
|
readAt: number;
|
|
84
78
|
msgCount: number;
|
|
@@ -376,7 +370,6 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
376
370
|
private runtimeMessages: Array<{ key: string; message: ChatMessage }> = [];
|
|
377
371
|
private lastPersistedHistoryMessages: PersistableCliHistoryMessage[] = [];
|
|
378
372
|
private lastAcknowledgedUserInputAt = 0;
|
|
379
|
-
private externalBusyIdleFingerprint = '';
|
|
380
373
|
private lastNativeSourceCanonicalCheckAt = 0;
|
|
381
374
|
private lastNativeSourceCanonicalCacheKey: string | undefined = undefined;
|
|
382
375
|
private cachedSqliteDb: {
|
|
@@ -599,17 +592,11 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
599
592
|
let visibleStatus = parseErrorMessage || parsedStatus?.status === 'error'
|
|
600
593
|
? 'error'
|
|
601
594
|
: (autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status);
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
//
|
|
607
|
-
// has not yet emitted a state_changed(idle) event but the parsed transcript
|
|
608
|
-
// already shows a final assistant turn, treat the session as idle so that
|
|
609
|
-
// getState() agrees with what detectStatusTransition already recorded via
|
|
610
|
-
// lastStatus. Without this guard, getState() returns 'generating' even after
|
|
611
|
-
// the instance's lastStatus has flipped to 'idle', causing the dashboard to
|
|
612
|
-
// show a perpetual generating spinner.
|
|
595
|
+
// getState() must agree with the status the FSM-driven detectStatusTransition()
|
|
596
|
+
// already committed to lastStatus. The adapter's own status is authoritative; we do
|
|
597
|
+
// not second-guess it with native-transcript shape. Only reconcile a generating-like
|
|
598
|
+
// read down to idle when our own lastStatus has already flipped idle (avoids a
|
|
599
|
+
// perpetual dashboard spinner during the brief window before the next getStatus()).
|
|
613
600
|
if (isCliGeneratingLikeStatus(visibleStatus) && this.lastStatus === 'idle') {
|
|
614
601
|
visibleStatus = 'idle';
|
|
615
602
|
}
|
|
@@ -885,7 +872,13 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
885
872
|
assertProviderSupportsDeclaredInput(this.provider, input);
|
|
886
873
|
const promptText = buildCliStructuredInputPrompt(input);
|
|
887
874
|
if (promptText) {
|
|
888
|
-
|
|
875
|
+
// force:true bypasses the busy/generating send guard so terminal mesh
|
|
876
|
+
// events (completion/failure/bootstrap) land in a coordinator session that
|
|
877
|
+
// is itself parked in `generating` while awaiting that very event.
|
|
878
|
+
// Without it the message is queued and only flushed on the coordinator's
|
|
879
|
+
// own idle transition — which never happens until it receives the message.
|
|
880
|
+
const force = data?.force === true;
|
|
881
|
+
void this.adapter.sendMessage(promptText, force ? { force: true } : {}).catch((e: any) => {
|
|
889
882
|
LOG.warn('CLI', `[${this.type}] send_message failed: ${e?.message || e}`);
|
|
890
883
|
});
|
|
891
884
|
}
|
|
@@ -934,7 +927,6 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
934
927
|
|
|
935
928
|
const receivedAt = Date.now();
|
|
936
929
|
this.lastAcknowledgedUserInputAt = receivedAt;
|
|
937
|
-
this.externalBusyIdleFingerprint = '';
|
|
938
930
|
const dedupKey = `user_input_ack:${crypto
|
|
939
931
|
.createHash('sha256')
|
|
940
932
|
.update(`${this.instanceId}:${content}:${receivedAt}`)
|
|
@@ -1100,59 +1092,6 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1100
1092
|
return extractFinalSummaryFromMessages(evidence.messages as any);
|
|
1101
1093
|
}
|
|
1102
1094
|
|
|
1103
|
-
private externalNativeFinalFingerprint(evidence: CompletionFinalAssistantEvidence): string {
|
|
1104
|
-
const messages = Array.isArray(evidence.messages) ? evidence.messages : [];
|
|
1105
|
-
const visibleMessages = messages.filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
|
|
1106
|
-
const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
|
|
1107
|
-
const content = lastVisible ? flattenContent(lastVisible.content).trim() : '';
|
|
1108
|
-
const receivedAt = lastVisible ? getMessageTime(lastVisible) : 0;
|
|
1109
|
-
const probe = this.lastExternalCompletionProbe;
|
|
1110
|
-
return crypto
|
|
1111
|
-
.createHash('sha256')
|
|
1112
|
-
.update([
|
|
1113
|
-
this.type,
|
|
1114
|
-
this.providerSessionId || '',
|
|
1115
|
-
probe?.sourcePath || '',
|
|
1116
|
-
String(probe?.sourceMtimeMs || 0),
|
|
1117
|
-
String(receivedAt || 0),
|
|
1118
|
-
content.slice(-500),
|
|
1119
|
-
].join('\0'))
|
|
1120
|
-
.digest('hex')
|
|
1121
|
-
.slice(0, 24);
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
private getExternalNativeFinalReconciliation(parsedMessages: unknown, adapterStatus: any): ExternalNativeFinalReconciliation | null {
|
|
1125
|
-
const rawStatus = typeof adapterStatus?.status === 'string' ? adapterStatus.status.trim() : '';
|
|
1126
|
-
if (!isCliGeneratingLikeStatus(rawStatus)) return null;
|
|
1127
|
-
if (hasNonEmptyCliModalButtons(adapterStatus?.activeModal ?? adapterStatus?.modal)) return null;
|
|
1128
|
-
|
|
1129
|
-
const evidence = this.completionFinalAssistantEvidence(parsedMessages);
|
|
1130
|
-
if (evidence.source !== 'external-native' || !evidence.present) return null;
|
|
1131
|
-
|
|
1132
|
-
const messages = Array.isArray(evidence.messages) ? evidence.messages : [];
|
|
1133
|
-
const visibleMessages = messages.filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
|
|
1134
|
-
const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
|
|
1135
|
-
const lastMessageAt = lastVisible ? getMessageTime(lastVisible) : 0;
|
|
1136
|
-
const sourceMtimeMs = Number(this.lastExternalCompletionProbe?.sourceMtimeMs || 0);
|
|
1137
|
-
const minEvidenceAt = Math.max(
|
|
1138
|
-
this.startedAt > 0 ? this.startedAt - 5_000 : 0,
|
|
1139
|
-
this.generatingStartedAt > 0 ? this.generatingStartedAt - 5_000 : 0,
|
|
1140
|
-
this.lastAcknowledgedUserInputAt > 0 ? this.lastAcknowledgedUserInputAt - 1_000 : 0,
|
|
1141
|
-
);
|
|
1142
|
-
if (minEvidenceAt > 0 && lastMessageAt > 0 && lastMessageAt < minEvidenceAt && sourceMtimeMs < minEvidenceAt) {
|
|
1143
|
-
return null;
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
const finalSummary = extractFinalSummaryFromMessages(evidence.messages as any);
|
|
1147
|
-
if (!finalSummary) return null;
|
|
1148
|
-
const fingerprint = this.externalNativeFinalFingerprint(evidence);
|
|
1149
|
-
if (fingerprint === this.externalBusyIdleFingerprint) {
|
|
1150
|
-
return { fingerprint, finalSummary, evidence };
|
|
1151
|
-
}
|
|
1152
|
-
this.externalBusyIdleFingerprint = fingerprint;
|
|
1153
|
-
return { fingerprint, finalSummary, evidence };
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
1095
|
private buildCompletedFinalizationDiagnostic(args: {
|
|
1157
1096
|
blockReason: string;
|
|
1158
1097
|
latestStatus?: any;
|
|
@@ -1242,13 +1181,12 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1242
1181
|
return true;
|
|
1243
1182
|
}
|
|
1244
1183
|
|
|
1245
|
-
private getCompletedFinalizationBlock(latestVisibleStatus: string, pending: CompletedDebouncePending
|
|
1184
|
+
private getCompletedFinalizationBlock(latestVisibleStatus: string, pending: CompletedDebouncePending): CompletedFinalizationBlock | null {
|
|
1246
1185
|
if (latestVisibleStatus !== 'idle') return { reason: `status:${latestVisibleStatus}`, terminal: true };
|
|
1247
1186
|
|
|
1248
1187
|
const adapterAny = this.adapter as any;
|
|
1249
1188
|
const approvalResolvedIdle = pending.previousStatus === 'waiting_approval';
|
|
1250
|
-
|
|
1251
|
-
if (!approvalResolvedIdle && !externalNativeFinal) {
|
|
1189
|
+
if (!approvalResolvedIdle) {
|
|
1252
1190
|
if (adapterAny?.isWaitingForResponse === true) return { reason: 'adapter_waiting_for_response', terminal: true };
|
|
1253
1191
|
if (adapterAny?.currentTurnScope) return { reason: 'adapter_turn_scope_active', terminal: true };
|
|
1254
1192
|
if (this.hasAdapterPendingResponse()) return { reason: 'adapter_pending_response', terminal: true };
|
|
@@ -1257,7 +1195,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1257
1195
|
const partial = typeof this.adapter.getPartialResponse === 'function'
|
|
1258
1196
|
? this.adapter.getPartialResponse()
|
|
1259
1197
|
: '';
|
|
1260
|
-
if (
|
|
1198
|
+
if (typeof partial === 'string' && partial.trim()) return { reason: 'partial_response_pending', terminal: true };
|
|
1261
1199
|
|
|
1262
1200
|
let parsed: any;
|
|
1263
1201
|
try {
|
|
@@ -1269,7 +1207,6 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1269
1207
|
const parsedStatus = typeof parsed?.status === 'string' ? parsed.status : 'unknown';
|
|
1270
1208
|
if (parsedStatus !== 'idle') {
|
|
1271
1209
|
const adapterStatus = this.adapter.getStatus({ allowParse: false });
|
|
1272
|
-
if (externalNativeFinal && isCliGeneratingLikeStatus(parsedStatus)) return null;
|
|
1273
1210
|
if (this.shouldSuppressStaleParsedBusyStatus(parsed, adapterStatus)) return null;
|
|
1274
1211
|
return { reason: `parsed_status:${parsedStatus}`, terminal: isCliGeneratingLikeStatus(parsedStatus) };
|
|
1275
1212
|
}
|
|
@@ -1341,11 +1278,8 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1341
1278
|
|
|
1342
1279
|
const latestStatus = this.adapter.getStatus({ allowParse: false });
|
|
1343
1280
|
const latestAutoApproveActive = latestStatus.status === 'waiting_approval' && this.shouldAutoApprove();
|
|
1344
|
-
const
|
|
1345
|
-
|
|
1346
|
-
? 'idle'
|
|
1347
|
-
: (latestAutoApproveActive || this.autoApproveBusy ? 'generating' : latestStatus.status);
|
|
1348
|
-
LOG.debug('CLI', `[${this.type}] flush attempt: adapterStatus=${latestStatus.status} latestVisible=${latestVisibleStatus} externalNativeFinal=${!!externalNativeFinal} generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse} hasPartial=${!!this.adapter.getPartialResponse?.()}`);
|
|
1281
|
+
const latestVisibleStatus = latestAutoApproveActive || this.autoApproveBusy ? 'generating' : latestStatus.status;
|
|
1282
|
+
LOG.debug('CLI', `[${this.type}] flush attempt: adapterStatus=${latestStatus.status} latestVisible=${latestVisibleStatus} generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse} hasPartial=${!!this.adapter.getPartialResponse?.()}`);
|
|
1349
1283
|
if (latestVisibleStatus !== 'idle') {
|
|
1350
1284
|
LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed ${latestVisibleStatus})`);
|
|
1351
1285
|
this.completedDebouncePending = null;
|
|
@@ -1353,7 +1287,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1353
1287
|
return;
|
|
1354
1288
|
}
|
|
1355
1289
|
|
|
1356
|
-
const block = this.getCompletedFinalizationBlock(latestVisibleStatus, pending
|
|
1290
|
+
const block = this.getCompletedFinalizationBlock(latestVisibleStatus, pending);
|
|
1357
1291
|
if (block) {
|
|
1358
1292
|
const blockReason = block.reason;
|
|
1359
1293
|
const waitedMs = Date.now() - pending.firstObservedAt;
|
|
@@ -1398,18 +1332,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1398
1332
|
chatTitle: pending.chatTitle,
|
|
1399
1333
|
duration: pending.duration,
|
|
1400
1334
|
timestamp: pending.timestamp,
|
|
1401
|
-
finalSummary:
|
|
1402
|
-
...(externalNativeFinal ? {
|
|
1403
|
-
completionDiagnostic: {
|
|
1404
|
-
providerType: this.type,
|
|
1405
|
-
sessionId: this.instanceId,
|
|
1406
|
-
providerSessionId: this.providerSessionId || null,
|
|
1407
|
-
reconciliationReason: 'external_native_final_assistant_while_adapter_busy',
|
|
1408
|
-
finalAssistantPresent: true,
|
|
1409
|
-
finalAssistantEvidenceSource: externalNativeFinal.evidence.source,
|
|
1410
|
-
externalFinalFingerprint: externalNativeFinal.fingerprint,
|
|
1411
|
-
},
|
|
1412
|
-
} : {}),
|
|
1335
|
+
finalSummary: this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages),
|
|
1413
1336
|
});
|
|
1414
1337
|
this.completedDebouncePending = null;
|
|
1415
1338
|
this.completedDebounceTimer = null;
|
|
@@ -1486,15 +1409,13 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1486
1409
|
const parsedStatus = null;
|
|
1487
1410
|
const rawStatus = adapterStatus.status;
|
|
1488
1411
|
const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, now);
|
|
1489
|
-
const externalNativeFinal = this.getExternalNativeFinalReconciliation(undefined, adapterStatus);
|
|
1490
1412
|
// During the autoApproveBusy window (2s after firing approval key), the PTY
|
|
1491
1413
|
// can briefly report 'idle' before the next generating phase starts. Treat that
|
|
1492
1414
|
// transient idle as 'generating' to suppress a spurious agent:generating_completed
|
|
1493
|
-
// push notification.
|
|
1415
|
+
// push notification. The adapter's status is otherwise authoritative — native
|
|
1416
|
+
// transcript shape does NOT override the FSM's busy/idle decision.
|
|
1494
1417
|
const autoApproveHoldIdle = this.autoApproveBusy && rawStatus === 'idle';
|
|
1495
|
-
const newStatus =
|
|
1496
|
-
? 'idle'
|
|
1497
|
-
: (autoApproveActive || autoApproveHoldIdle ? 'generating' : rawStatus);
|
|
1418
|
+
const newStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : rawStatus;
|
|
1498
1419
|
const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
|
|
1499
1420
|
const chatTitle = `${this.provider.name} · ${dirName}`;
|
|
1500
1421
|
const partial = this.adapter.getPartialResponse();
|
|
@@ -68,7 +68,7 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
68
68
|
native_history?: NativeHistoryConfig;
|
|
69
69
|
};
|
|
70
70
|
private lastEvent: DashboardEvent | null = null;
|
|
71
|
-
private latestState: { id: string; label: string; title: string | null } | null = null;
|
|
71
|
+
private latestState: { id: string; label: string; title: string | null; status: 'idle' | 'generating' | 'approval' } | null = null;
|
|
72
72
|
private latestModal: { title: string | null; buttons: { index: number; label: string }[] } | null = null;
|
|
73
73
|
private statusCallback: (() => void) | null = null;
|
|
74
74
|
private ptyDataCallback: ((data: string) => void) | null = null;
|
|
@@ -159,21 +159,25 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
159
159
|
const state = this.latestState;
|
|
160
160
|
if (!state) return { status: 'starting', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt, ...sessionFields };
|
|
161
161
|
|
|
162
|
+
// The FSM state is authoritative for status. We do NOT infer status from whether
|
|
163
|
+
// a modal was parsed this frame: a modal/approval state whose buttons briefly fail
|
|
164
|
+
// to parse (PTY repaint) must still report waiting_approval, not collapse to idle —
|
|
165
|
+
// that collapse fired false completions while a session sat at an approval prompt.
|
|
162
166
|
const modal = this.latestModal;
|
|
163
|
-
|
|
164
|
-
if (modal) {
|
|
167
|
+
if (state.status === 'approval') {
|
|
165
168
|
return {
|
|
166
169
|
status: 'waiting_approval',
|
|
167
170
|
messages: [],
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
171
|
+
// Surface buttons when we have them; an approval state with no parsed
|
|
172
|
+
// modal this frame still stays waiting_approval (no activeModal yet).
|
|
173
|
+
activeModal: modal
|
|
174
|
+
? { message: modal.title ?? state.label, buttons: modal.buttons.map(b => b.label) }
|
|
175
|
+
: null,
|
|
172
176
|
activeInteractivePrompt: this.activeInteractivePrompt,
|
|
173
177
|
...sessionFields,
|
|
174
178
|
};
|
|
175
179
|
}
|
|
176
|
-
if (
|
|
180
|
+
if (state.status === 'generating') {
|
|
177
181
|
return { status: 'generating', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt, ...sessionFields };
|
|
178
182
|
}
|
|
179
183
|
return { status: 'idle', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt, ...sessionFields };
|