shennian 0.3.85 → 0.3.86
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/bin/shennian.js +1 -1
- package/dist/publish-build-manifest.json +28 -28
- package/dist/src/agents/adapter.js +19 -1
- package/dist/src/agents/claude-managed-permissions.js +248 -1
- package/dist/src/agents/claude.js +393 -8
- package/dist/src/agents/codex-managed-permissions.js +294 -1
- package/dist/src/agents/codex-managed-room-tools.js +249 -1
- package/dist/src/agents/codex-utils.js +200 -7
- package/dist/src/agents/codex.js +1352 -17
- package/dist/src/agents/config-status.js +226 -1
- package/dist/src/agents/cursor-managed-permissions.js +299 -2
- package/dist/src/agents/cursor.js +314 -1
- package/dist/src/agents/custom.js +418 -4
- package/dist/src/agents/external-channel-instructions.js +94 -11
- package/dist/src/agents/gemini.js +180 -1
- package/dist/src/agents/manager.d.ts +2 -0
- package/dist/src/agents/manager.js +250 -18
- package/dist/src/agents/model-registry/cache.js +37 -1
- package/dist/src/agents/model-registry/discovery.js +188 -2
- package/dist/src/agents/model-registry/parsers.js +521 -4
- package/dist/src/agents/model-registry/runner.js +30 -1
- package/dist/src/agents/model-registry/service.js +115 -1
- package/dist/src/agents/model-registry/types.js +8 -1
- package/dist/src/agents/model-registry.js +19 -1
- package/dist/src/agents/native-capabilities.js +237 -1
- package/dist/src/agents/openclaw.js +293 -2
- package/dist/src/agents/opencode-managed-permissions.js +187 -2
- package/dist/src/agents/opencode.js +298 -1
- package/dist/src/agents/platform-instructions.js +46 -17
- package/dist/src/agents/runtime-compatibility.js +44 -1
- package/dist/src/agents/workbuddy-managed-permissions.js +329 -2
- package/dist/src/agents/workbuddy.js +444 -3
- package/dist/src/commands/room.js +2 -2
- package/dist/src/fs/boundary.js +185 -1
- package/dist/src/fs/handler.js +130 -1
- package/dist/src/fs/security.js +32 -1
- package/dist/src/fs/text-decoder.js +110 -1
- package/dist/src/index.js +587 -3
- package/dist/src/manager/prompt.js +34 -29
- package/dist/src/manager/registry.js +317 -1
- package/dist/src/manager/retired-external-channel-compat.js +40 -1
- package/dist/src/manager/runtime.d.ts +4 -0
- package/dist/src/manager/runtime.js +1514 -23
- package/dist/src/native-fusion/codex-parser.js +660 -4
- package/dist/src/native-fusion/codex-user-input.js +102 -5
- package/dist/src/native-fusion/config.js +5 -1
- package/dist/src/native-fusion/file-watcher.js +191 -1
- package/dist/src/native-fusion/local-repository.js +569 -2
- package/dist/src/native-fusion/opencode-parser.js +130 -3
- package/dist/src/native-fusion/parsers.js +140 -3
- package/dist/src/native-fusion/service.js +739 -2
- package/dist/src/native-fusion/session-header.js +101 -2
- package/dist/src/native-fusion/state.js +104 -2
- package/dist/src/native-fusion/types.js +1 -1
- package/dist/src/personal-sync/machine-authority.js +34 -1
- package/dist/src/room/activation-audit.js +45 -2
- package/dist/src/room/activation-policy.js +30 -1
- package/dist/src/room/activation-service.d.ts +1 -0
- package/dist/src/room/activation-service.js +408 -8
- package/dist/src/room/invite-bootstrap.js +156 -1
- package/dist/src/room/managed-context.d.ts +1 -1
- package/dist/src/room/managed-context.js +1 -1
- package/dist/src/room/managed-prompt-composer.d.ts +1 -1
- package/dist/src/room/managed-prompt-composer.js +6 -5
- package/dist/src/room/managed-room-binding.js +167 -1
- package/dist/src/room/managed-room-ipc.d.ts +2 -2
- package/dist/src/room/managed-room-ipc.js +2 -2
- package/dist/src/room/mirror-sync-service.js +183 -1
- package/dist/src/room/room-action-client.d.ts +11 -0
- package/dist/src/room/room-action-client.js +1 -1
- package/dist/src/room/room-binding-client.d.ts +1 -0
- package/dist/src/room/room-binding-client.js +151 -1
- package/dist/src/security/managed-agent-policy.d.ts +3 -2
- package/dist/src/security/managed-agent-policy.js +1 -1
- package/dist/src/session/archive-zip.js +220 -1
- package/dist/src/session/deletion-sync-state.js +136 -2
- package/dist/src/session/handlers/agent-capabilities.js +14 -1
- package/dist/src/session/handlers/agent-config.js +150 -1
- package/dist/src/session/handlers/agents.js +55 -1
- package/dist/src/session/handlers/chat.d.ts +1 -1
- package/dist/src/session/handlers/chat.js +1030 -2
- package/dist/src/session/handlers/control.js +61 -1
- package/dist/src/session/handlers/fs.js +594 -1
- package/dist/src/session/handlers/message-status.js +15 -1
- package/dist/src/session/handlers/remote-access.js +43 -1
- package/dist/src/session/handlers/room-managed.js +101 -1
- package/dist/src/session/handlers/session-refresh.js +35 -1
- package/dist/src/session/handlers/title.js +79 -1
- package/dist/src/session/handlers/tool-detail.js +149 -1
- package/dist/src/session/handlers/voice-window.js +62 -1
- package/dist/src/session/history-subscriptions.js +470 -1
- package/dist/src/session/manager.js +658 -1
- package/dist/src/session/native-cleanup-outbox.js +287 -2
- package/dist/src/session/native-session-lifecycle.js +327 -6
- package/dist/src/session/queue.d.ts +5 -0
- package/dist/src/session/queue.js +8 -8
- package/dist/src/upgrade/scheduler.js +126 -1
- package/node_modules/@shennian/wire/dist/agent-workspace.js +1 -1
- package/package.json +1 -1
|
@@ -1,2 +1,739 @@
|
|
|
1
|
-
|
|
2
|
-
`).trim()}function C(){return new Promise(d=>setImmediate(d))}function oe(d){return d.split(/[\\/]+/).includes(".codex")}class me{client;repository;timer=null;activeTimer=null;scanPromise=null;queuedTargetFiles=new Set;pendingBoundaryFiles=new Set;sourceFiles=new Map;fullScanQueued=!1;watchEnabled=!1;lifecycleRevision=0;watcher=new R(e=>{this.scanNow(e).catch(s=>{console.error("[native-fusion] watched file scan failed",s)})});pendingClaims;suppressionWindows;externalObservations=new Map;externalTerminalHandler=null;constructor(e,s=new L){this.client=e,this.repository=s,this.pendingClaims=new Map((this.repository.listManagedClaims?.()??[]).map(t=>[t.canonicalMessageId,t])),this.suppressionWindows=new Map((this.repository.listManagedSuppressions?.()??[]).map(t=>[I(t.agentType,t.sourceSessionKey),t]))}start(){this.timer||(this.timer=setInterval(()=>{this.scanNow().catch(e=>{console.error("[native-fusion] periodic scan failed",e)})},G))}stop(){this.lifecycleRevision+=1,this.timer&&clearInterval(this.timer),this.timer=null,this.activeTimer&&clearInterval(this.activeTimer),this.activeTimer=null,this.pendingBoundaryFiles.clear(),this.watchEnabled=!1,this.watcher.stop()}setExternalTerminalHandler(e){this.externalTerminalHandler=e}handleConnected(){const e=++this.lifecycleRevision;this.watchEnabled=!0,this.scanNow().catch(s=>{console.error("[native-fusion] initial scan failed",s)}).finally(()=>{this.lifecycleRevision===e&&this.start()})}registerManagedSend(e){if(!e.canonicalMessageId)return;const s=e.sourceAgentType??e.agentType;if(s!=="codex"&&s!=="claude"&&s!=="opencode")return;const t=e.canonicalMessageId,i=this.pendingClaims.get(t),a=P(e.text);if(i&&(i.sessionId!==e.sessionId||i.sourceAgentType!==s||i.textHash!==a))throw new Error(`managed echo claim identity conflict: ${t}`);const u={sessionId:e.sessionId,agentType:e.agentType,sourceAgentType:s,canonicalMessageId:e.canonicalMessageId,textHash:a,createdAt:i?.createdAt??Date.now(),sourceSessionKey:e.sourceSessionKey??i?.sourceSessionKey??null,managedEchoPolicy:e.managedEchoPolicy??"suppress_following",externalRunId:e.externalRunId??null};if(this.repository.upsertManagedClaim?.(u),this.pendingClaims.set(t,u),e.externalRunId&&e.sourceSessionKey){const p=`${s}:${e.sourceSessionKey}`,y=this.externalObservations.get(p)??[];y.push({sessionId:e.sessionId,sourceSessionKey:e.sourceSessionKey,runId:e.externalRunId,canonicalMessageId:e.canonicalMessageId,state:"queued",createdAt:Date.now(),lastPublishedAt:Date.now()}),this.externalObservations.set(p,y),this.emitExternalActivity(y[y.length-1],"queued"),this.ensureActiveScanning()}}cancelManagedSend(e){this.repository.removeManagedClaim?.(e),this.pendingClaims.delete(e)}noteManagedSourceSession(e,s,t){if(t){this.repository.updateManagedClaimSource?.(e,s,t);for(const i of this.pendingClaims.values())i.sessionId===e&&i.sourceAgentType===s&&(i.sourceSessionKey=t)}}handleSessionDeleted(e){for(const[s,t]of this.pendingClaims.entries())t.sessionId===e&&this.pendingClaims.delete(s);for(const[s,t]of this.suppressionWindows.entries())t.sessionId===e&&this.suppressionWindows.delete(s);for(const[s,t]of this.externalObservations.entries()){const i=t.filter(a=>a.sessionId!==e);i.length===0?this.externalObservations.delete(s):this.externalObservations.set(s,i)}this.repository.purgeSession(e)}ensureActiveScanning(e=!0){this.activeTimer||(this.activeTimer=setInterval(()=>{if(this.externalObservations.size===0&&this.pendingBoundaryFiles.size===0){this.activeTimer&&clearInterval(this.activeTimer),this.activeTimer=null;return}this.scanActiveTargets()},se),e&&this.scanActiveTargets())}scanActiveTargets(){const e=this.activeScanTargets();e===void 0&&this.scanPromise||this.scanNow(e).catch(s=>{console.error("[native-fusion] active observation scan failed",s)})}activeScanTargets(){const e=[...this.externalObservations.keys()];if(!e.some(s=>!this.sourceFiles.has(s)))return[...new Set([...this.pendingBoundaryFiles,...e.flatMap(s=>{const t=this.sourceFiles.get(s);return t?[t]:[]})])]}emitExternalActivity(e,s){this.client.sendAgentEvent({type:"event",event:"agent",id:`external-owner-${e.runId}-${s}-${e.lastPublishedAt}`,payload:{state:s==="queued"?"start":"heartbeat",sessionId:e.sessionId,runId:e.runId,seq:s==="queued"?0:1,runPhase:"thinking",canStop:!1,owner:"external",ownerLabel:"Codex Desktop",lifecycle:s}})}emitExternalTerminal(e,s="completed"){const t=s==="completed"?"final":s==="aborted"?"aborted":"error";this.client.sendAgentEvent({type:"event",event:"agent",id:`external-owner-${e.runId}-${t}`,payload:{state:t,sessionId:e.sessionId,runId:e.runId,seq:2,...t==="error"?{message:"Codex Desktop turn failed"}:{},owner:"external",ownerLabel:"Codex Desktop"}}),this.externalTerminalHandler?.(e.sessionId)}pruneState(){const e=Date.now();for(const[s,t]of this.suppressionWindows.entries())t.endsAt>e||(this.repository.removeManagedSuppression?.(t.agentType,t.sourceSessionKey),this.suppressionWindows.delete(s))}async scanNow(e){if(this.client.getState()==="connected"){if(this.scanPromise){const s=this.scanPromise;if(e)for(const t of e)this.queuedTargetFiles.add(t);else this.fullScanQueued=!0;if(await s,this.fullScanQueued)return this.fullScanQueued=!1,this.queuedTargetFiles.clear(),this.scanNow();if(this.queuedTargetFiles.size>0){const t=[...this.queuedTargetFiles];return this.queuedTargetFiles.clear(),this.scanNow(t)}return}return this.scanPromise=this.runScanLoop(e).finally(()=>{this.scanPromise=null}),this.scanPromise}}async runScanLoop(e){let s=e?[...new Set(e)]:void 0;for(;;){if(await this.runScan(s),this.fullScanQueued){await C(),this.fullScanQueued=!1,this.queuedTargetFiles.clear(),s=void 0;continue}if(this.queuedTargetFiles.size>0){await C(),s=[...this.queuedTargetFiles],this.queuedTargetFiles.clear();continue}return}}async runScan(e){this.pruneState(),this.refreshExternalObservations();const s=j(),t={...s.files},i=[],a=new Map,u=new Map,p=new Map,y=e?[...e]:[...W(),...D(),...$()];!e&&this.watchEnabled&&this.watcher.refresh(y,z());for(let n=0;n<y.length;n+=1){const o=y[n];if(n>0&&n%te===0&&await C(),!B.existsSync(o)){this.watchEnabled&&this.watcher.untrackFile(o),this.pendingBoundaryFiles.delete(o);for(const[r,f]of this.sourceFiles.entries())f===o&&this.sourceFiles.delete(r);continue}this.watchEnabled&&this.watcher.trackFile(o);const l=B.statSync(o),E=oe(o),h=o.endsWith(".opencode-session.json");let c=s.files[o];const x=`${String(l.dev)}:${String(l.ino)}:${String(l.birthtimeMs)}`;let b=!c;if(c&&!h&&(l.size<c.offset||c.fileIdentity&&c.fileIdentity!==x)&&(c=void 0,b=!0),!!(c&&c.fileIdentity===x&&c.mtimeMs===l.mtimeMs&&c.offset>=l.size&&c.sources?.length)&&c){for(const r of c.sources??[])this.sourceFiles.set(I(r.agentType,r.sourceSessionKey),o);c.classification==="live"&&c.awaitingUserBoundary?this.pendingBoundaryFiles.add(o):this.pendingBoundaryFiles.delete(o);continue}const g=c?.sources?.length&&!h?{status:"ready",createdAtMs:c.sessionCreatedAtMs??l.birthtimeMs,sources:c.sources}:U(o);if(g.status==="pending"){l.birthtimeMs>=s.fusionEpochMs&&this.pendingBoundaryFiles.add(o);continue}for(const r of g.sources)this.sourceFiles.set(I(r.agentType,r.sourceSessionKey),o);if(!h&&g.sources.length>0&&g.sources.every(r=>this.repository.isSourceDeleted?.(r.agentType,r.sourceSessionKey))){t[o]={...c??{daemonInstallationId:s.daemonInstallationId,classification:g.createdAtMs>=s.fusionEpochMs?"live":"historical",awaitingUserBoundary:!0,sessionCreatedAtMs:g.createdAtMs,sourceBoundaryEstablished:{}},offset:l.size,mtimeMs:l.mtimeMs,fileIdentity:x};continue}if(!c)c={daemonInstallationId:s.daemonInstallationId,offset:0,mtimeMs:l.mtimeMs,classification:g.createdAtMs>=s.fusionEpochMs?"live":"historical",awaitingUserBoundary:!0,sessionCreatedAtMs:g.createdAtMs,sourceBoundaryEstablished:{}};else if(h&&c.mtimeMs===l.mtimeMs)continue;const A=h?0:c.offset,w=h?Y(o,A):E?Q(o,A):V(o,A);for(const r of w.events)this.repository.isSourceDeleted?.(r.agentType,r.sourceSessionKey)||r.title?.trim()&&r.titleSource==="agent_native"&&p.set(`${r.agentType}:${r.sourceSessionKey}`,r);let K,S;if(h){const r=ae(w.events,c,s.fusionEpochMs,b,l.mtimeMs,w.nextOffset);K=r.events,S=r.cursor}else if(b&&c.classification==="historical")K=[],S={...c,offset:w.nextOffset,mtimeMs:l.mtimeMs,awaitingUserBoundary:!0};else{const r=re(w.events,c,w.nextOffset,l.mtimeMs);K=r.events,S=r.cursor}S.fileIdentity=x,S.sources=g.sources,t[o]=S,w.nextOffset<l.size&&l.size-A>X&&this.queuedTargetFiles.add(o),S.classification==="live"&&S.awaitingUserBoundary?this.pendingBoundaryFiles.add(o):this.pendingBoundaryFiles.delete(o);for(const r of K){if(this.repository.isSourceDeleted?.(r.agentType,r.sourceSessionKey))continue;const f=this.tryClaimManagedEcho(r);if(f){i.push(f.event),a.set(f.claimKey,f.suppression??null),f.suppression&&u.set(I(f.suppression.agentType,f.suppression.sourceSessionKey),{window:f.suppression,claimKey:f.claimKey});continue}const _=this.suppressionFor(r,u,a);if(_){i.push({...r,claimSessionId:_.sessionId,localDisposition:"managed_echo_suppressed"});continue}i.push(r)}}const v=[...i].sort((n,o)=>n.ts-o.ts),T=new Set,m=new Set,M=n=>{try{this.repository.commitEvents(n)}catch(o){if(!(o instanceof q))throw o;const l=n.find(h=>h.sourceEventKey===o.sourceEventKey);if(!l)throw o;T.add(l.sourceEventKey),l.claimCanonicalMessageId&&m.add(l.claimCanonicalMessageId),console.warn(`[native-fusion] quarantined conflicting native event ${l.sourceEventKey}`);const E=n.filter(h=>h!==l);E.length>0&&M(E)}};v.length===0&&this.repository.commitEvents([]);for(let n=0;n<v.length;n+=F)M(v.slice(n,n+F)),n+F<v.length&&await C();for(const[n,o]of a)m.has(n)||(this.repository.completeManagedClaim?.(n,o??void 0),this.pendingClaims.delete(n),o&&this.suppressionWindows.set(I(o.agentType,o.sourceSessionKey),o));for(const n of p.values())this.repository.observeNativeTitle(n.agentType,n.sourceSessionKey,n.title??"");for(const n of this.repository.listSourceBindings()){if(n.agentType!=="codex")continue;const o=H(n.sourceSessionKey);o&&this.repository.observeNativeTitle("codex",n.sourceSessionKey,o)}for(const n of v)T.has(n.sourceEventKey)||this.observeExternalLifecycle(n);s.files=t,J(s),this.pendingBoundaryFiles.size>0&&this.ensureActiveScanning(!1),!(this.fullScanQueued||this.queuedTargetFiles.size>0)&&(this.repository.repairMissingUserBoundaries?.(),await k(this.client).catch(n=>{console.error("[native-fusion] index projection sync failed; retained for retry",n)}))}refreshExternalObservations(){const e=Date.now();for(const[s,t]of this.externalObservations.entries()){const i=t.filter(u=>e-u.createdAt<N);if(i.length===0){this.externalObservations.delete(s);continue}this.externalObservations.set(s,i);const a=i[0];e-a.lastPublishedAt<ie||(a.lastPublishedAt=e,this.emitExternalActivity(a,a.state))}}tryClaimManagedEcho(e){const s=Date.now();for(const[t,i]of this.pendingClaims.entries()){if(i.sourceAgentType!==e.agentType||e.role!=="user"||!i.sourceSessionKey||i.sourceSessionKey!==e.sourceSessionKey)continue;const a=i.externalRunId?N:Z;if(Math.abs(e.ts-i.createdAt)>a||P(e.payload)!==i.textHash)continue;const u=i.managedEchoPolicy==="suppress_following"?{sessionId:i.sessionId,agentType:e.agentType,sourceSessionKey:e.sourceSessionKey,claimSourceEventKey:e.sourceEventKey,startedAt:e.ts,endsAt:Math.max(s,e.ts)+ee}:void 0;return{claimKey:t,suppression:u,event:{...e,sourceMode:"managed_echo_claim",claimCanonicalMessageId:i.canonicalMessageId,claimSessionId:i.sessionId,managedEchoPolicy:i.managedEchoPolicy}}}return null}observeExternalLifecycle(e){const s=`${e.agentType}:${e.sourceSessionKey}`,t=this.externalObservations.get(s);if(!t?.length)return;const i=t[0];if(e.claimCanonicalMessageId===i.canonicalMessageId&&i.state==="queued"&&(this.repository.updateMessageDeliveryState(i.sessionId,i.canonicalMessageId,"executing"),i.state="running",i.lastPublishedAt=Date.now(),this.emitExternalActivity(i,"running")),!e.terminal||i.state!=="running")return;const a=e.terminalState??"completed";this.repository.updateMessageDeliveryState(i.sessionId,i.canonicalMessageId,a==="aborted"?"cancelled":a==="failed"?"failed":"completed"),this.emitExternalTerminal(i,a),t.shift(),t.length===0?this.externalObservations.delete(s):(this.externalObservations.set(s,t),t[0].lastPublishedAt=Date.now(),this.emitExternalActivity(t[0],"queued"))}suppressionFor(e,s,t){const i=I(e.agentType,e.sourceSessionKey),a=s.get(i),u=a?.window??this.suppressionWindows.get(i);return!u||e.ts<u.startedAt?null:e.ts>u.endsAt?(this.clearSuppression(i,u,a,s,t),null):e.role==="user"?(e.sourceEventKey===u.claimSourceEventKey||this.clearSuppression(i,u,a,s,t),null):u}clearSuppression(e,s,t,i,a){if(t){i.delete(e),a.set(t.claimKey,null);return}this.repository.removeManagedSuppression?.(s.agentType,s.sourceSessionKey),this.suppressionWindows.delete(e)}}function P(d){return O.createHash("sha256").update(ne(d)).digest("hex")}function I(d,e){return`${d}:${e}`}function re(d,e,s,t){if(!e.awaitingUserBoundary)return{events:d,cursor:{...e,offset:s,mtimeMs:t}};if(d.length===0)return{events:[],cursor:{...e,offset:s,mtimeMs:t,awaitingUserBoundary:!0}};const i=d.findIndex(a=>a.role==="user");return i<0?{events:[],cursor:{...e,mtimeMs:t,awaitingUserBoundary:!0}}:{events:d.slice(i),cursor:{...e,offset:s,mtimeMs:t,awaitingUserBoundary:!1}}}function ae(d,e,s,t,i,a){const u={...e.sourceBoundaryEstablished??{}},p=e.eventWatermarkTs??-1,y=new Set(e.eventKeysAtWatermark??[]),v=t?d:d.filter(n=>n.ts>p||n.ts===p&&!y.has(n.sourceEventKey)),T=[];for(const n of v){const o=n.sourceSessionKey;if(u[o]){T.push(n);continue}!(!t||(n.sourceSessionCreatedAtMs??0)>=s)||n.role!=="user"||(u[o]=!0,T.push(n))}const m=d.reduce((n,o)=>Math.max(n,o.ts),p),M=new Set(m===p?e.eventKeysAtWatermark??[]:[]);for(const n of d)n.ts===m&&M.add(n.sourceEventKey);return{events:T,cursor:{...e,offset:a,mtimeMs:i,eventWatermarkTs:m>=0?m:void 0,eventKeysAtWatermark:m>=0?[...M]:void 0,sourceBoundaryEstablished:u}}}export{me as NativeSessionFusionService};
|
|
1
|
+
// @arch docs/architecture/cli/native-session-fusion.md
|
|
2
|
+
// @test src/__tests__/native-fusion-parsers.test.ts
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { publishPendingSessionIndexes } from '../session/index-publisher.js';
|
|
6
|
+
import { NativeFusionFileWatcher } from './file-watcher.js';
|
|
7
|
+
import { NativeEventIdentityConflictError, NativeFusionLocalRepository, } from './local-repository.js';
|
|
8
|
+
import { listClaudeTranscriptFiles, listCodexRolloutFiles, listOpenCodeSessionFiles, listNativeTranscriptRoots, inspectNativeSessionFile, lookupCodexThreadName, parseClaudeTranscriptChunk, parseCodexRolloutChunk, parseOpenCodeSessionFile, } from './parsers.js';
|
|
9
|
+
import { MAX_JSONL_SCAN_BYTES_PER_PASS } from './parser-common.js';
|
|
10
|
+
import { loadNativeScannerState, saveNativeScannerState } from './state.js';
|
|
11
|
+
const SCAN_INTERVAL_MS = 5 * 60_000;
|
|
12
|
+
const CLAIM_TTL_MS = 2 * 60_000;
|
|
13
|
+
const MANAGED_RUN_SUPPRESSION_TTL_MS = 30 * 60_000;
|
|
14
|
+
const ACTIVE_SCAN_INTERVAL_MS = 2_000;
|
|
15
|
+
const SCAN_YIELD_EVERY_FILES = 1;
|
|
16
|
+
const COMMIT_YIELD_EVERY_EVENTS = 16;
|
|
17
|
+
const EXTERNAL_ACTIVITY_HEARTBEAT_MS = 30_000;
|
|
18
|
+
const EXTERNAL_OBSERVATION_TTL_MS = 4 * 60 * 60_000;
|
|
19
|
+
function normalizeText(text) {
|
|
20
|
+
return text.replace(/\r\n/g, '\n').trim();
|
|
21
|
+
}
|
|
22
|
+
function yieldToEventLoop() {
|
|
23
|
+
return new Promise((resolve) => setImmediate(resolve));
|
|
24
|
+
}
|
|
25
|
+
function isCodexRolloutPath(filePath) {
|
|
26
|
+
return filePath.split(/[\\/]+/).includes('.codex');
|
|
27
|
+
}
|
|
28
|
+
export class NativeSessionFusionService {
|
|
29
|
+
client;
|
|
30
|
+
repository;
|
|
31
|
+
timer = null;
|
|
32
|
+
activeTimer = null;
|
|
33
|
+
scanPromise = null;
|
|
34
|
+
queuedTargetFiles = new Set();
|
|
35
|
+
pendingBoundaryFiles = new Set();
|
|
36
|
+
sourceFiles = new Map();
|
|
37
|
+
fullScanQueued = false;
|
|
38
|
+
watchEnabled = false;
|
|
39
|
+
lifecycleRevision = 0;
|
|
40
|
+
watcher = new NativeFusionFileWatcher((filePaths) => {
|
|
41
|
+
void this.scanNow(filePaths).catch((error) => {
|
|
42
|
+
console.error('[native-fusion] watched file scan failed', error);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
pendingClaims;
|
|
46
|
+
suppressionWindows;
|
|
47
|
+
externalObservations = new Map();
|
|
48
|
+
externalTerminalHandler = null;
|
|
49
|
+
constructor(client, repository = new NativeFusionLocalRepository()) {
|
|
50
|
+
this.client = client;
|
|
51
|
+
this.repository = repository;
|
|
52
|
+
this.pendingClaims = new Map((this.repository.listManagedClaims?.() ?? [])
|
|
53
|
+
.map((claim) => [claim.canonicalMessageId, claim]));
|
|
54
|
+
this.suppressionWindows = new Map((this.repository.listManagedSuppressions?.() ?? [])
|
|
55
|
+
.map((window) => [sourceKey(window.agentType, window.sourceSessionKey), window]));
|
|
56
|
+
}
|
|
57
|
+
start() {
|
|
58
|
+
if (this.timer)
|
|
59
|
+
return;
|
|
60
|
+
this.timer = setInterval(() => {
|
|
61
|
+
void this.scanNow().catch((error) => {
|
|
62
|
+
console.error('[native-fusion] periodic scan failed', error);
|
|
63
|
+
});
|
|
64
|
+
}, SCAN_INTERVAL_MS);
|
|
65
|
+
}
|
|
66
|
+
stop() {
|
|
67
|
+
this.lifecycleRevision += 1;
|
|
68
|
+
if (this.timer)
|
|
69
|
+
clearInterval(this.timer);
|
|
70
|
+
this.timer = null;
|
|
71
|
+
if (this.activeTimer)
|
|
72
|
+
clearInterval(this.activeTimer);
|
|
73
|
+
this.activeTimer = null;
|
|
74
|
+
this.pendingBoundaryFiles.clear();
|
|
75
|
+
this.watchEnabled = false;
|
|
76
|
+
this.watcher.stop();
|
|
77
|
+
}
|
|
78
|
+
setExternalTerminalHandler(handler) {
|
|
79
|
+
this.externalTerminalHandler = handler;
|
|
80
|
+
}
|
|
81
|
+
handleConnected() {
|
|
82
|
+
const lifecycleRevision = ++this.lifecycleRevision;
|
|
83
|
+
this.watchEnabled = true;
|
|
84
|
+
void this.scanNow()
|
|
85
|
+
.catch((error) => {
|
|
86
|
+
console.error('[native-fusion] initial scan failed', error);
|
|
87
|
+
})
|
|
88
|
+
.finally(() => {
|
|
89
|
+
if (this.lifecycleRevision === lifecycleRevision)
|
|
90
|
+
this.start();
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
registerManagedSend(params) {
|
|
94
|
+
if (!params.canonicalMessageId)
|
|
95
|
+
return;
|
|
96
|
+
const sourceAgentType = params.sourceAgentType ?? params.agentType;
|
|
97
|
+
if (sourceAgentType !== 'codex' &&
|
|
98
|
+
sourceAgentType !== 'claude' &&
|
|
99
|
+
sourceAgentType !== 'opencode')
|
|
100
|
+
return;
|
|
101
|
+
const claimKey = params.canonicalMessageId;
|
|
102
|
+
const existing = this.pendingClaims.get(claimKey);
|
|
103
|
+
const textHash = normalizedTextHash(params.text);
|
|
104
|
+
if (existing && (existing.sessionId !== params.sessionId
|
|
105
|
+
|| existing.sourceAgentType !== sourceAgentType
|
|
106
|
+
|| existing.textHash !== textHash)) {
|
|
107
|
+
throw new Error(`managed echo claim identity conflict: ${claimKey}`);
|
|
108
|
+
}
|
|
109
|
+
const claim = {
|
|
110
|
+
sessionId: params.sessionId,
|
|
111
|
+
agentType: params.agentType,
|
|
112
|
+
sourceAgentType,
|
|
113
|
+
canonicalMessageId: params.canonicalMessageId,
|
|
114
|
+
textHash,
|
|
115
|
+
createdAt: existing?.createdAt ?? Date.now(),
|
|
116
|
+
sourceSessionKey: params.sourceSessionKey ?? existing?.sourceSessionKey ?? null,
|
|
117
|
+
managedEchoPolicy: params.managedEchoPolicy ?? 'suppress_following',
|
|
118
|
+
externalRunId: params.externalRunId ?? null,
|
|
119
|
+
};
|
|
120
|
+
this.repository.upsertManagedClaim?.(claim);
|
|
121
|
+
this.pendingClaims.set(claimKey, claim);
|
|
122
|
+
if (params.externalRunId && params.sourceSessionKey) {
|
|
123
|
+
const key = `${sourceAgentType}:${params.sourceSessionKey}`;
|
|
124
|
+
const observations = this.externalObservations.get(key) ?? [];
|
|
125
|
+
observations.push({
|
|
126
|
+
sessionId: params.sessionId,
|
|
127
|
+
sourceSessionKey: params.sourceSessionKey,
|
|
128
|
+
runId: params.externalRunId,
|
|
129
|
+
canonicalMessageId: params.canonicalMessageId,
|
|
130
|
+
state: 'queued',
|
|
131
|
+
createdAt: Date.now(),
|
|
132
|
+
lastPublishedAt: Date.now(),
|
|
133
|
+
});
|
|
134
|
+
this.externalObservations.set(key, observations);
|
|
135
|
+
this.emitExternalActivity(observations[observations.length - 1], 'queued');
|
|
136
|
+
this.ensureActiveScanning();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
cancelManagedSend(canonicalMessageId) {
|
|
140
|
+
this.repository.removeManagedClaim?.(canonicalMessageId);
|
|
141
|
+
this.pendingClaims.delete(canonicalMessageId);
|
|
142
|
+
}
|
|
143
|
+
noteManagedSourceSession(sessionId, agentType, sourceSessionKey) {
|
|
144
|
+
if (!sourceSessionKey)
|
|
145
|
+
return;
|
|
146
|
+
this.repository.updateManagedClaimSource?.(sessionId, agentType, sourceSessionKey);
|
|
147
|
+
for (const claim of this.pendingClaims.values()) {
|
|
148
|
+
if (claim.sessionId === sessionId && claim.sourceAgentType === agentType) {
|
|
149
|
+
claim.sourceSessionKey = sourceSessionKey;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
handleSessionDeleted(sessionId) {
|
|
154
|
+
for (const [key, claim] of this.pendingClaims.entries()) {
|
|
155
|
+
if (claim.sessionId === sessionId)
|
|
156
|
+
this.pendingClaims.delete(key);
|
|
157
|
+
}
|
|
158
|
+
for (const [key, window] of this.suppressionWindows.entries()) {
|
|
159
|
+
if (window.sessionId === sessionId)
|
|
160
|
+
this.suppressionWindows.delete(key);
|
|
161
|
+
}
|
|
162
|
+
for (const [key, observations] of this.externalObservations.entries()) {
|
|
163
|
+
const remaining = observations.filter((observation) => observation.sessionId !== sessionId);
|
|
164
|
+
if (remaining.length === 0)
|
|
165
|
+
this.externalObservations.delete(key);
|
|
166
|
+
else
|
|
167
|
+
this.externalObservations.set(key, remaining);
|
|
168
|
+
}
|
|
169
|
+
this.repository.purgeSession(sessionId);
|
|
170
|
+
}
|
|
171
|
+
ensureActiveScanning(runImmediately = true) {
|
|
172
|
+
if (this.activeTimer)
|
|
173
|
+
return;
|
|
174
|
+
this.activeTimer = setInterval(() => {
|
|
175
|
+
if (this.externalObservations.size === 0 && this.pendingBoundaryFiles.size === 0) {
|
|
176
|
+
if (this.activeTimer)
|
|
177
|
+
clearInterval(this.activeTimer);
|
|
178
|
+
this.activeTimer = null;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
this.scanActiveTargets();
|
|
182
|
+
}, ACTIVE_SCAN_INTERVAL_MS);
|
|
183
|
+
if (runImmediately)
|
|
184
|
+
this.scanActiveTargets();
|
|
185
|
+
}
|
|
186
|
+
scanActiveTargets() {
|
|
187
|
+
const targets = this.activeScanTargets();
|
|
188
|
+
// One in-flight full reconciliation is already locating an observation's
|
|
189
|
+
// source. Do not queue another full pass every two seconds behind it.
|
|
190
|
+
if (targets === undefined && this.scanPromise)
|
|
191
|
+
return;
|
|
192
|
+
void this.scanNow(targets).catch((error) => {
|
|
193
|
+
console.error('[native-fusion] active observation scan failed', error);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
activeScanTargets() {
|
|
197
|
+
const observedSourceKeys = [...this.externalObservations.keys()];
|
|
198
|
+
if (observedSourceKeys.some((key) => !this.sourceFiles.has(key)))
|
|
199
|
+
return undefined;
|
|
200
|
+
return [...new Set([
|
|
201
|
+
...this.pendingBoundaryFiles,
|
|
202
|
+
...observedSourceKeys.flatMap((key) => {
|
|
203
|
+
const filePath = this.sourceFiles.get(key);
|
|
204
|
+
return filePath ? [filePath] : [];
|
|
205
|
+
}),
|
|
206
|
+
])];
|
|
207
|
+
}
|
|
208
|
+
emitExternalActivity(observation, lifecycle) {
|
|
209
|
+
this.client.sendAgentEvent({
|
|
210
|
+
type: 'event',
|
|
211
|
+
event: 'agent',
|
|
212
|
+
id: `external-owner-${observation.runId}-${lifecycle}-${observation.lastPublishedAt}`,
|
|
213
|
+
payload: {
|
|
214
|
+
state: lifecycle === 'queued' ? 'start' : 'heartbeat',
|
|
215
|
+
sessionId: observation.sessionId,
|
|
216
|
+
runId: observation.runId,
|
|
217
|
+
seq: lifecycle === 'queued' ? 0 : 1,
|
|
218
|
+
runPhase: 'thinking',
|
|
219
|
+
canStop: false,
|
|
220
|
+
owner: 'external',
|
|
221
|
+
ownerLabel: 'Codex Desktop',
|
|
222
|
+
lifecycle,
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
emitExternalTerminal(observation, terminalState = 'completed') {
|
|
227
|
+
const state = terminalState === 'completed'
|
|
228
|
+
? 'final'
|
|
229
|
+
: terminalState === 'aborted'
|
|
230
|
+
? 'aborted'
|
|
231
|
+
: 'error';
|
|
232
|
+
this.client.sendAgentEvent({
|
|
233
|
+
type: 'event',
|
|
234
|
+
event: 'agent',
|
|
235
|
+
id: `external-owner-${observation.runId}-${state}`,
|
|
236
|
+
payload: {
|
|
237
|
+
state,
|
|
238
|
+
sessionId: observation.sessionId,
|
|
239
|
+
runId: observation.runId,
|
|
240
|
+
seq: 2,
|
|
241
|
+
...(state === 'error' ? { message: 'Codex Desktop turn failed' } : {}),
|
|
242
|
+
owner: 'external',
|
|
243
|
+
ownerLabel: 'Codex Desktop',
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
// The relay event updates remote clients, but the local SessionManager also
|
|
247
|
+
// owns the durable queue. Wake it explicitly so an externally-owned Codex
|
|
248
|
+
// turn cannot leave the next mobile message queued forever.
|
|
249
|
+
this.externalTerminalHandler?.(observation.sessionId);
|
|
250
|
+
}
|
|
251
|
+
pruneState() {
|
|
252
|
+
const now = Date.now();
|
|
253
|
+
for (const [key, window] of this.suppressionWindows.entries()) {
|
|
254
|
+
if (window.endsAt > now)
|
|
255
|
+
continue;
|
|
256
|
+
this.repository.removeManagedSuppression?.(window.agentType, window.sourceSessionKey);
|
|
257
|
+
this.suppressionWindows.delete(key);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
async scanNow(targetFiles) {
|
|
261
|
+
if (this.client.getState() !== 'connected')
|
|
262
|
+
return;
|
|
263
|
+
if (this.scanPromise) {
|
|
264
|
+
const activeScan = this.scanPromise;
|
|
265
|
+
if (targetFiles) {
|
|
266
|
+
for (const filePath of targetFiles)
|
|
267
|
+
this.queuedTargetFiles.add(filePath);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
this.fullScanQueued = true;
|
|
271
|
+
}
|
|
272
|
+
await activeScan;
|
|
273
|
+
if (this.fullScanQueued) {
|
|
274
|
+
this.fullScanQueued = false;
|
|
275
|
+
this.queuedTargetFiles.clear();
|
|
276
|
+
return this.scanNow();
|
|
277
|
+
}
|
|
278
|
+
if (this.queuedTargetFiles.size > 0) {
|
|
279
|
+
const queued = [...this.queuedTargetFiles];
|
|
280
|
+
this.queuedTargetFiles.clear();
|
|
281
|
+
return this.scanNow(queued);
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
this.scanPromise = this.runScanLoop(targetFiles).finally(() => {
|
|
286
|
+
this.scanPromise = null;
|
|
287
|
+
});
|
|
288
|
+
return this.scanPromise;
|
|
289
|
+
}
|
|
290
|
+
async runScanLoop(initialTargets) {
|
|
291
|
+
let targets = initialTargets ? [...new Set(initialTargets)] : undefined;
|
|
292
|
+
while (true) {
|
|
293
|
+
await this.runScan(targets);
|
|
294
|
+
if (this.fullScanQueued) {
|
|
295
|
+
await yieldToEventLoop();
|
|
296
|
+
this.fullScanQueued = false;
|
|
297
|
+
this.queuedTargetFiles.clear();
|
|
298
|
+
targets = undefined;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (this.queuedTargetFiles.size > 0) {
|
|
302
|
+
await yieldToEventLoop();
|
|
303
|
+
targets = [...this.queuedTargetFiles];
|
|
304
|
+
this.queuedTargetFiles.clear();
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async runScan(targetFiles) {
|
|
311
|
+
this.pruneState();
|
|
312
|
+
this.refreshExternalObservations();
|
|
313
|
+
const state = loadNativeScannerState();
|
|
314
|
+
const nextFilesState = { ...state.files };
|
|
315
|
+
const batches = [];
|
|
316
|
+
const claimTransitions = new Map();
|
|
317
|
+
const stagedSuppressions = new Map();
|
|
318
|
+
const observedTitles = new Map();
|
|
319
|
+
const files = targetFiles ? [...targetFiles] : [
|
|
320
|
+
...listCodexRolloutFiles(),
|
|
321
|
+
...listClaudeTranscriptFiles(),
|
|
322
|
+
...listOpenCodeSessionFiles(),
|
|
323
|
+
];
|
|
324
|
+
if (!targetFiles && this.watchEnabled)
|
|
325
|
+
this.watcher.refresh(files, listNativeTranscriptRoots());
|
|
326
|
+
for (let fileIndex = 0; fileIndex < files.length; fileIndex += 1) {
|
|
327
|
+
const filePath = files[fileIndex];
|
|
328
|
+
if (fileIndex > 0 && fileIndex % SCAN_YIELD_EVERY_FILES === 0) {
|
|
329
|
+
await yieldToEventLoop();
|
|
330
|
+
}
|
|
331
|
+
if (!fs.existsSync(filePath)) {
|
|
332
|
+
if (this.watchEnabled)
|
|
333
|
+
this.watcher.untrackFile(filePath);
|
|
334
|
+
this.pendingBoundaryFiles.delete(filePath);
|
|
335
|
+
for (const [key, mappedPath] of this.sourceFiles.entries()) {
|
|
336
|
+
if (mappedPath === filePath)
|
|
337
|
+
this.sourceFiles.delete(key);
|
|
338
|
+
}
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (this.watchEnabled)
|
|
342
|
+
this.watcher.trackFile(filePath);
|
|
343
|
+
const stat = fs.statSync(filePath);
|
|
344
|
+
const isCodex = isCodexRolloutPath(filePath);
|
|
345
|
+
const isOpenCode = filePath.endsWith('.opencode-session.json');
|
|
346
|
+
let current = state.files[filePath];
|
|
347
|
+
const fileIdentity = `${String(stat.dev)}:${String(stat.ino)}:${String(stat.birthtimeMs)}`;
|
|
348
|
+
let isNewFile = !current;
|
|
349
|
+
if (current && !isOpenCode && (stat.size < current.offset
|
|
350
|
+
|| (current.fileIdentity && current.fileIdentity !== fileIdentity))) {
|
|
351
|
+
current = undefined;
|
|
352
|
+
isNewFile = true;
|
|
353
|
+
}
|
|
354
|
+
const unchanged = Boolean(current
|
|
355
|
+
&& current.fileIdentity === fileIdentity
|
|
356
|
+
&& current.mtimeMs === stat.mtimeMs
|
|
357
|
+
&& current.offset >= stat.size
|
|
358
|
+
&& current.sources?.length);
|
|
359
|
+
if (unchanged && current) {
|
|
360
|
+
for (const source of current.sources ?? []) {
|
|
361
|
+
this.sourceFiles.set(sourceKey(source.agentType, source.sourceSessionKey), filePath);
|
|
362
|
+
}
|
|
363
|
+
if (current.classification === 'live' && current.awaitingUserBoundary) {
|
|
364
|
+
this.pendingBoundaryFiles.add(filePath);
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
this.pendingBoundaryFiles.delete(filePath);
|
|
368
|
+
}
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
const inspection = current?.sources?.length && !isOpenCode
|
|
372
|
+
? {
|
|
373
|
+
status: 'ready',
|
|
374
|
+
createdAtMs: current.sessionCreatedAtMs ?? stat.birthtimeMs,
|
|
375
|
+
sources: current.sources,
|
|
376
|
+
}
|
|
377
|
+
: inspectNativeSessionFile(filePath);
|
|
378
|
+
if (inspection.status === 'pending') {
|
|
379
|
+
if (stat.birthtimeMs >= state.fusionEpochMs)
|
|
380
|
+
this.pendingBoundaryFiles.add(filePath);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
for (const source of inspection.sources) {
|
|
384
|
+
this.sourceFiles.set(sourceKey(source.agentType, source.sourceSessionKey), filePath);
|
|
385
|
+
}
|
|
386
|
+
if (!isOpenCode
|
|
387
|
+
&& inspection.sources.length > 0
|
|
388
|
+
&& inspection.sources.every((source) => (this.repository.isSourceDeleted?.(source.agentType, source.sourceSessionKey)))) {
|
|
389
|
+
nextFilesState[filePath] = {
|
|
390
|
+
...(current ?? {
|
|
391
|
+
daemonInstallationId: state.daemonInstallationId,
|
|
392
|
+
classification: inspection.createdAtMs >= state.fusionEpochMs ? 'live' : 'historical',
|
|
393
|
+
awaitingUserBoundary: true,
|
|
394
|
+
sessionCreatedAtMs: inspection.createdAtMs,
|
|
395
|
+
sourceBoundaryEstablished: {},
|
|
396
|
+
}),
|
|
397
|
+
offset: stat.size,
|
|
398
|
+
mtimeMs: stat.mtimeMs,
|
|
399
|
+
fileIdentity,
|
|
400
|
+
};
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
if (!current) {
|
|
404
|
+
current = {
|
|
405
|
+
daemonInstallationId: state.daemonInstallationId,
|
|
406
|
+
offset: 0,
|
|
407
|
+
mtimeMs: stat.mtimeMs,
|
|
408
|
+
classification: inspection.createdAtMs >= state.fusionEpochMs ? 'live' : 'historical',
|
|
409
|
+
awaitingUserBoundary: true,
|
|
410
|
+
sessionCreatedAtMs: inspection.createdAtMs,
|
|
411
|
+
sourceBoundaryEstablished: {},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
else if (isOpenCode && current.mtimeMs === stat.mtimeMs) {
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const startOffset = isOpenCode ? 0 : current.offset;
|
|
418
|
+
const parsed = isOpenCode
|
|
419
|
+
? parseOpenCodeSessionFile(filePath, startOffset)
|
|
420
|
+
: isCodex
|
|
421
|
+
? parseCodexRolloutChunk(filePath, startOffset)
|
|
422
|
+
: parseClaudeTranscriptChunk(filePath, startOffset);
|
|
423
|
+
for (const event of parsed.events) {
|
|
424
|
+
if (this.repository.isSourceDeleted?.(event.agentType, event.sourceSessionKey))
|
|
425
|
+
continue;
|
|
426
|
+
if (event.title?.trim() && event.titleSource === 'agent_native') {
|
|
427
|
+
observedTitles.set(`${event.agentType}:${event.sourceSessionKey}`, event);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
let acceptedEvents;
|
|
431
|
+
let nextFileState;
|
|
432
|
+
if (isOpenCode) {
|
|
433
|
+
const planned = planOpenCodeSnapshot(parsed.events, current, state.fusionEpochMs, isNewFile, stat.mtimeMs, parsed.nextOffset);
|
|
434
|
+
acceptedEvents = planned.events;
|
|
435
|
+
nextFileState = planned.cursor;
|
|
436
|
+
}
|
|
437
|
+
else if (isNewFile && current.classification === 'historical') {
|
|
438
|
+
acceptedEvents = [];
|
|
439
|
+
nextFileState = {
|
|
440
|
+
...current,
|
|
441
|
+
offset: parsed.nextOffset,
|
|
442
|
+
mtimeMs: stat.mtimeMs,
|
|
443
|
+
awaitingUserBoundary: true,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
const planned = planJsonlEvents(parsed.events, current, parsed.nextOffset, stat.mtimeMs);
|
|
448
|
+
acceptedEvents = planned.events;
|
|
449
|
+
nextFileState = planned.cursor;
|
|
450
|
+
}
|
|
451
|
+
nextFileState.fileIdentity = fileIdentity;
|
|
452
|
+
nextFileState.sources = inspection.sources;
|
|
453
|
+
nextFilesState[filePath] = nextFileState;
|
|
454
|
+
if (parsed.nextOffset < stat.size
|
|
455
|
+
&& stat.size - startOffset > MAX_JSONL_SCAN_BYTES_PER_PASS) {
|
|
456
|
+
this.queuedTargetFiles.add(filePath);
|
|
457
|
+
}
|
|
458
|
+
if (nextFileState.classification === 'live' && nextFileState.awaitingUserBoundary) {
|
|
459
|
+
this.pendingBoundaryFiles.add(filePath);
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
this.pendingBoundaryFiles.delete(filePath);
|
|
463
|
+
}
|
|
464
|
+
for (const event of acceptedEvents) {
|
|
465
|
+
if (this.repository.isSourceDeleted?.(event.agentType, event.sourceSessionKey))
|
|
466
|
+
continue;
|
|
467
|
+
const claimed = this.tryClaimManagedEcho(event);
|
|
468
|
+
if (claimed) {
|
|
469
|
+
batches.push(claimed.event);
|
|
470
|
+
claimTransitions.set(claimed.claimKey, claimed.suppression ?? null);
|
|
471
|
+
if (claimed.suppression) {
|
|
472
|
+
stagedSuppressions.set(sourceKey(claimed.suppression.agentType, claimed.suppression.sourceSessionKey), { window: claimed.suppression, claimKey: claimed.claimKey });
|
|
473
|
+
}
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
const suppression = this.suppressionFor(event, stagedSuppressions, claimTransitions);
|
|
477
|
+
if (suppression) {
|
|
478
|
+
batches.push({
|
|
479
|
+
...event,
|
|
480
|
+
claimSessionId: suppression.sessionId,
|
|
481
|
+
localDisposition: 'managed_echo_suppressed',
|
|
482
|
+
});
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
batches.push(event);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
const orderedBatches = [...batches].sort((left, right) => left.ts - right.ts);
|
|
489
|
+
const quarantinedEventKeys = new Set();
|
|
490
|
+
const quarantinedClaimKeys = new Set();
|
|
491
|
+
const commitBatch = (events) => {
|
|
492
|
+
try {
|
|
493
|
+
this.repository.commitEvents(events);
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
if (!(error instanceof NativeEventIdentityConflictError))
|
|
497
|
+
throw error;
|
|
498
|
+
const conflict = events.find((event) => event.sourceEventKey === error.sourceEventKey);
|
|
499
|
+
if (!conflict)
|
|
500
|
+
throw error;
|
|
501
|
+
quarantinedEventKeys.add(conflict.sourceEventKey);
|
|
502
|
+
if (conflict.claimCanonicalMessageId) {
|
|
503
|
+
quarantinedClaimKeys.add(conflict.claimCanonicalMessageId);
|
|
504
|
+
}
|
|
505
|
+
console.warn(`[native-fusion] quarantined conflicting native event ${conflict.sourceEventKey}`);
|
|
506
|
+
const remaining = events.filter((event) => event !== conflict);
|
|
507
|
+
if (remaining.length > 0)
|
|
508
|
+
commitBatch(remaining);
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
if (orderedBatches.length === 0)
|
|
512
|
+
this.repository.commitEvents([]);
|
|
513
|
+
for (let start = 0; start < orderedBatches.length; start += COMMIT_YIELD_EVERY_EVENTS) {
|
|
514
|
+
commitBatch(orderedBatches.slice(start, start + COMMIT_YIELD_EVERY_EVENTS));
|
|
515
|
+
if (start + COMMIT_YIELD_EVERY_EVENTS < orderedBatches.length)
|
|
516
|
+
await yieldToEventLoop();
|
|
517
|
+
}
|
|
518
|
+
for (const [claimKey, suppression] of claimTransitions) {
|
|
519
|
+
if (quarantinedClaimKeys.has(claimKey))
|
|
520
|
+
continue;
|
|
521
|
+
this.repository.completeManagedClaim?.(claimKey, suppression ?? undefined);
|
|
522
|
+
this.pendingClaims.delete(claimKey);
|
|
523
|
+
if (suppression) {
|
|
524
|
+
this.suppressionWindows.set(sourceKey(suppression.agentType, suppression.sourceSessionKey), suppression);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
for (const event of observedTitles.values()) {
|
|
528
|
+
this.repository.observeNativeTitle(event.agentType, event.sourceSessionKey, event.title ?? '');
|
|
529
|
+
}
|
|
530
|
+
for (const binding of this.repository.listSourceBindings()) {
|
|
531
|
+
if (binding.agentType !== 'codex')
|
|
532
|
+
continue;
|
|
533
|
+
const title = lookupCodexThreadName(binding.sourceSessionKey);
|
|
534
|
+
if (title)
|
|
535
|
+
this.repository.observeNativeTitle('codex', binding.sourceSessionKey, title);
|
|
536
|
+
}
|
|
537
|
+
for (const event of orderedBatches) {
|
|
538
|
+
if (!quarantinedEventKeys.has(event.sourceEventKey))
|
|
539
|
+
this.observeExternalLifecycle(event);
|
|
540
|
+
}
|
|
541
|
+
state.files = nextFilesState;
|
|
542
|
+
saveNativeScannerState(state);
|
|
543
|
+
if (this.pendingBoundaryFiles.size > 0)
|
|
544
|
+
this.ensureActiveScanning(false);
|
|
545
|
+
// A large transcript is reconciled over several bounded passes. Publishing
|
|
546
|
+
// after every pass repeats the full dirty session projection and can flood
|
|
547
|
+
// the relay with large, superseded index frames. Keep the dirty projection
|
|
548
|
+
// local until the queued backlog drains, then publish its final revision
|
|
549
|
+
// once. The scan loop always consumes these queued targets before returning.
|
|
550
|
+
if (this.fullScanQueued || this.queuedTargetFiles.size > 0)
|
|
551
|
+
return;
|
|
552
|
+
this.repository.repairMissingUserBoundaries?.();
|
|
553
|
+
await publishPendingSessionIndexes(this.client).catch((error) => {
|
|
554
|
+
console.error('[native-fusion] index projection sync failed; retained for retry', error);
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
refreshExternalObservations() {
|
|
558
|
+
const now = Date.now();
|
|
559
|
+
for (const [key, observations] of this.externalObservations.entries()) {
|
|
560
|
+
const active = observations.filter((observation) => now - observation.createdAt < EXTERNAL_OBSERVATION_TTL_MS);
|
|
561
|
+
if (active.length === 0) {
|
|
562
|
+
this.externalObservations.delete(key);
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
this.externalObservations.set(key, active);
|
|
566
|
+
const current = active[0];
|
|
567
|
+
if (now - current.lastPublishedAt < EXTERNAL_ACTIVITY_HEARTBEAT_MS)
|
|
568
|
+
continue;
|
|
569
|
+
current.lastPublishedAt = now;
|
|
570
|
+
this.emitExternalActivity(current, current.state);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
tryClaimManagedEcho(event) {
|
|
574
|
+
const now = Date.now();
|
|
575
|
+
for (const [claimKey, claim] of this.pendingClaims.entries()) {
|
|
576
|
+
if (claim.sourceAgentType !== event.agentType)
|
|
577
|
+
continue;
|
|
578
|
+
if (event.role !== 'user')
|
|
579
|
+
continue;
|
|
580
|
+
if (!claim.sourceSessionKey)
|
|
581
|
+
continue;
|
|
582
|
+
if (claim.sourceSessionKey !== event.sourceSessionKey)
|
|
583
|
+
continue;
|
|
584
|
+
const claimWindowMs = claim.externalRunId ? EXTERNAL_OBSERVATION_TTL_MS : CLAIM_TTL_MS;
|
|
585
|
+
if (Math.abs(event.ts - claim.createdAt) > claimWindowMs)
|
|
586
|
+
continue;
|
|
587
|
+
if (normalizedTextHash(event.payload) !== claim.textHash)
|
|
588
|
+
continue;
|
|
589
|
+
const suppression = claim.managedEchoPolicy === 'suppress_following'
|
|
590
|
+
? {
|
|
591
|
+
sessionId: claim.sessionId,
|
|
592
|
+
agentType: event.agentType,
|
|
593
|
+
sourceSessionKey: event.sourceSessionKey,
|
|
594
|
+
claimSourceEventKey: event.sourceEventKey,
|
|
595
|
+
startedAt: event.ts,
|
|
596
|
+
endsAt: Math.max(now, event.ts) + MANAGED_RUN_SUPPRESSION_TTL_MS,
|
|
597
|
+
}
|
|
598
|
+
: undefined;
|
|
599
|
+
return {
|
|
600
|
+
claimKey,
|
|
601
|
+
suppression,
|
|
602
|
+
event: {
|
|
603
|
+
...event,
|
|
604
|
+
sourceMode: 'managed_echo_claim',
|
|
605
|
+
claimCanonicalMessageId: claim.canonicalMessageId,
|
|
606
|
+
claimSessionId: claim.sessionId,
|
|
607
|
+
managedEchoPolicy: claim.managedEchoPolicy,
|
|
608
|
+
},
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
observeExternalLifecycle(event) {
|
|
614
|
+
const key = `${event.agentType}:${event.sourceSessionKey}`;
|
|
615
|
+
const observations = this.externalObservations.get(key);
|
|
616
|
+
if (!observations?.length)
|
|
617
|
+
return;
|
|
618
|
+
const current = observations[0];
|
|
619
|
+
if (event.claimCanonicalMessageId === current.canonicalMessageId &&
|
|
620
|
+
current.state === 'queued') {
|
|
621
|
+
this.repository.updateMessageDeliveryState(current.sessionId, current.canonicalMessageId, 'executing');
|
|
622
|
+
current.state = 'running';
|
|
623
|
+
current.lastPublishedAt = Date.now();
|
|
624
|
+
this.emitExternalActivity(current, 'running');
|
|
625
|
+
}
|
|
626
|
+
if (!event.terminal || current.state !== 'running')
|
|
627
|
+
return;
|
|
628
|
+
const terminalState = event.terminalState ?? 'completed';
|
|
629
|
+
this.repository.updateMessageDeliveryState(current.sessionId, current.canonicalMessageId, terminalState === 'aborted'
|
|
630
|
+
? 'cancelled'
|
|
631
|
+
: terminalState === 'failed'
|
|
632
|
+
? 'failed'
|
|
633
|
+
: 'completed');
|
|
634
|
+
this.emitExternalTerminal(current, terminalState);
|
|
635
|
+
observations.shift();
|
|
636
|
+
if (observations.length === 0) {
|
|
637
|
+
this.externalObservations.delete(key);
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
this.externalObservations.set(key, observations);
|
|
641
|
+
observations[0].lastPublishedAt = Date.now();
|
|
642
|
+
this.emitExternalActivity(observations[0], 'queued');
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
suppressionFor(event, staged, transitions) {
|
|
646
|
+
const key = sourceKey(event.agentType, event.sourceSessionKey);
|
|
647
|
+
const stagedEntry = staged.get(key);
|
|
648
|
+
const window = stagedEntry?.window ?? this.suppressionWindows.get(key);
|
|
649
|
+
if (!window || event.ts < window.startedAt)
|
|
650
|
+
return null;
|
|
651
|
+
if (event.ts > window.endsAt) {
|
|
652
|
+
this.clearSuppression(key, window, stagedEntry, staged, transitions);
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
if (event.role === 'user') {
|
|
656
|
+
// A cursor rollback may replay the user event that established this
|
|
657
|
+
// suppression window. It is not a new terminal-authored turn and must
|
|
658
|
+
// not release suppression for agent output that arrived after the crash.
|
|
659
|
+
if (event.sourceEventKey === window.claimSourceEventKey)
|
|
660
|
+
return null;
|
|
661
|
+
this.clearSuppression(key, window, stagedEntry, staged, transitions);
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
return window;
|
|
665
|
+
}
|
|
666
|
+
clearSuppression(key, window, stagedEntry, staged, transitions) {
|
|
667
|
+
if (stagedEntry) {
|
|
668
|
+
staged.delete(key);
|
|
669
|
+
transitions.set(stagedEntry.claimKey, null);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
this.repository.removeManagedSuppression?.(window.agentType, window.sourceSessionKey);
|
|
673
|
+
this.suppressionWindows.delete(key);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
function normalizedTextHash(text) {
|
|
677
|
+
return crypto.createHash('sha256').update(normalizeText(text)).digest('hex');
|
|
678
|
+
}
|
|
679
|
+
function sourceKey(agentType, sourceSessionKey) {
|
|
680
|
+
return `${agentType}:${sourceSessionKey}`;
|
|
681
|
+
}
|
|
682
|
+
function planJsonlEvents(events, current, nextOffset, mtimeMs) {
|
|
683
|
+
if (!current.awaitingUserBoundary) {
|
|
684
|
+
return { events, cursor: { ...current, offset: nextOffset, mtimeMs } };
|
|
685
|
+
}
|
|
686
|
+
if (events.length === 0) {
|
|
687
|
+
return {
|
|
688
|
+
events: [],
|
|
689
|
+
cursor: { ...current, offset: nextOffset, mtimeMs, awaitingUserBoundary: true },
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
const userBoundary = events.findIndex((event) => event.role === 'user');
|
|
693
|
+
if (userBoundary < 0) {
|
|
694
|
+
return { events: [], cursor: { ...current, mtimeMs, awaitingUserBoundary: true } };
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
events: events.slice(userBoundary),
|
|
698
|
+
cursor: { ...current, offset: nextOffset, mtimeMs, awaitingUserBoundary: false },
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
function planOpenCodeSnapshot(events, current, fusionEpochMs, isNewFile, mtimeMs, nextOffset) {
|
|
702
|
+
const established = { ...(current.sourceBoundaryEstablished ?? {}) };
|
|
703
|
+
const watermarkTs = current.eventWatermarkTs ?? -1;
|
|
704
|
+
const keysAtWatermark = new Set(current.eventKeysAtWatermark ?? []);
|
|
705
|
+
const candidates = isNewFile
|
|
706
|
+
? events
|
|
707
|
+
: events.filter((event) => event.ts > watermarkTs
|
|
708
|
+
|| (event.ts === watermarkTs && !keysAtWatermark.has(event.sourceEventKey)));
|
|
709
|
+
const accepted = [];
|
|
710
|
+
for (const event of candidates) {
|
|
711
|
+
const sourceKey = event.sourceSessionKey;
|
|
712
|
+
if (established[sourceKey]) {
|
|
713
|
+
accepted.push(event);
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
const sourceIsLive = !isNewFile || (event.sourceSessionCreatedAtMs ?? 0) >= fusionEpochMs;
|
|
717
|
+
if (!sourceIsLive || event.role !== 'user')
|
|
718
|
+
continue;
|
|
719
|
+
established[sourceKey] = true;
|
|
720
|
+
accepted.push(event);
|
|
721
|
+
}
|
|
722
|
+
const maxTimestamp = events.reduce((maximum, event) => Math.max(maximum, event.ts), watermarkTs);
|
|
723
|
+
const nextKeySet = new Set(maxTimestamp === watermarkTs ? current.eventKeysAtWatermark ?? [] : []);
|
|
724
|
+
for (const event of events) {
|
|
725
|
+
if (event.ts === maxTimestamp)
|
|
726
|
+
nextKeySet.add(event.sourceEventKey);
|
|
727
|
+
}
|
|
728
|
+
return {
|
|
729
|
+
events: accepted,
|
|
730
|
+
cursor: {
|
|
731
|
+
...current,
|
|
732
|
+
offset: nextOffset,
|
|
733
|
+
mtimeMs,
|
|
734
|
+
eventWatermarkTs: maxTimestamp >= 0 ? maxTimestamp : undefined,
|
|
735
|
+
eventKeysAtWatermark: maxTimestamp >= 0 ? [...nextKeySet] : undefined,
|
|
736
|
+
sourceBoundaryEstablished: established,
|
|
737
|
+
},
|
|
738
|
+
};
|
|
739
|
+
}
|