@adhdev/daemon-core 0.9.82-rc.253 → 0.9.82-rc.255
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 +185 -121
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +185 -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 +55 -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 +49 -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,
|
|
@@ -4292,6 +4309,27 @@ export class DaemonCommandRouter {
|
|
|
4292
4309
|
}
|
|
4293
4310
|
|
|
4294
4311
|
case 'launch_cli': {
|
|
4312
|
+
// Worker launch envelope hardening: a mesh worker session must carry
|
|
4313
|
+
// meshCoordinatorDaemonId so completion events route back to the
|
|
4314
|
+
// coordinator (the cloud relay and the daemon-core forwarder both key on
|
|
4315
|
+
// it). mesh_launch_session resolves coordinatorNode.daemonId || ctx.localDaemonId
|
|
4316
|
+
// upstream, but for a freshly-cloned local worktree both can be empty. When the
|
|
4317
|
+
// launch is a coordinator-driven worker on this machine and the id is missing,
|
|
4318
|
+
// stamp this daemon's own id — worker and coordinator are co-located here.
|
|
4319
|
+
{
|
|
4320
|
+
const launchSettings = (args?.settings && typeof args.settings === 'object')
|
|
4321
|
+
? args.settings as Record<string, unknown>
|
|
4322
|
+
: undefined;
|
|
4323
|
+
const isMeshWorkerLaunch = !!launchSettings
|
|
4324
|
+
&& (readStringValue(launchSettings.meshNodeFor) || launchSettings.launchedByCoordinator === true);
|
|
4325
|
+
const hasCoordinatorDaemonId = !!launchSettings && !!readStringValue(launchSettings.meshCoordinatorDaemonId);
|
|
4326
|
+
if (launchSettings && isMeshWorkerLaunch && !hasCoordinatorDaemonId) {
|
|
4327
|
+
try {
|
|
4328
|
+
const localDaemonId = readStringValue(loadConfig().machineId);
|
|
4329
|
+
if (localDaemonId) launchSettings.meshCoordinatorDaemonId = localDaemonId;
|
|
4330
|
+
} catch { /* best-effort — launch proceeds without the stamp */ }
|
|
4331
|
+
}
|
|
4332
|
+
}
|
|
4295
4333
|
const launchResult = await this.deps.cliManager.handleCliCommand(cmd, args);
|
|
4296
4334
|
// Bug C fix (part 1): when launching a mesh node worker session, surface
|
|
4297
4335
|
// bootstrapPending:true if the node's worktree bootstrap is still running.
|
|
@@ -6005,7 +6043,16 @@ export class DaemonCommandRouter {
|
|
|
6005
6043
|
if (ownerFailure) return ownerFailure;
|
|
6006
6044
|
|
|
6007
6045
|
try {
|
|
6008
|
-
|
|
6046
|
+
// Resolve with preferInline so the clone writes the new node into the
|
|
6047
|
+
// same representation that get_mesh reads back. The MCP coordinator
|
|
6048
|
+
// passes inlineMesh on every mesh command, so when it owns an inline
|
|
6049
|
+
// mesh the membership read path (get_mesh, preferInline: true) returns
|
|
6050
|
+
// the inline cache. Without preferInline here, clone could resolve to a
|
|
6051
|
+
// local-config mesh and write the node only to config — leaving the
|
|
6052
|
+
// inline cache (and therefore get_mesh / refreshMeshFromDaemon) without
|
|
6053
|
+
// the node, so the new worktree node is never visible in live mesh
|
|
6054
|
+
// membership even though worktree_bootstrap_complete fires.
|
|
6055
|
+
const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
|
|
6009
6056
|
const mesh = meshRecord?.mesh;
|
|
6010
6057
|
if (!mesh) return { success: false, error: 'Mesh not found' };
|
|
6011
6058
|
|
|
@@ -6064,6 +6111,13 @@ export class DaemonCommandRouter {
|
|
|
6064
6111
|
policy: { ...(sourceNode.policy || {}) },
|
|
6065
6112
|
});
|
|
6066
6113
|
if (!node) return { success: false, error: 'Failed to register worktree node' };
|
|
6114
|
+
// Also reconcile the freshly-registered node into any warmed inline
|
|
6115
|
+
// cache for this mesh. get_mesh (preferInline: true) reads the inline
|
|
6116
|
+
// cache first when one exists; if we only wrote to local config the
|
|
6117
|
+
// node would be invisible to membership reads. updateInlineMeshNode is
|
|
6118
|
+
// a no-op when no inline cache is present.
|
|
6119
|
+
const inlineForReconcile = this.getCachedInlineMesh(meshId);
|
|
6120
|
+
if (inlineForReconcile) this.updateInlineMeshNode(meshId, inlineForReconcile, node);
|
|
6067
6121
|
this.invalidateAggregateMeshStatus(meshId);
|
|
6068
6122
|
}
|
|
6069
6123
|
|
|
@@ -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,
|