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.
Files changed (99) hide show
  1. package/dist/bin/shennian.js +1 -1
  2. package/dist/publish-build-manifest.json +28 -28
  3. package/dist/src/agents/adapter.js +19 -1
  4. package/dist/src/agents/claude-managed-permissions.js +248 -1
  5. package/dist/src/agents/claude.js +393 -8
  6. package/dist/src/agents/codex-managed-permissions.js +294 -1
  7. package/dist/src/agents/codex-managed-room-tools.js +249 -1
  8. package/dist/src/agents/codex-utils.js +200 -7
  9. package/dist/src/agents/codex.js +1352 -17
  10. package/dist/src/agents/config-status.js +226 -1
  11. package/dist/src/agents/cursor-managed-permissions.js +299 -2
  12. package/dist/src/agents/cursor.js +314 -1
  13. package/dist/src/agents/custom.js +418 -4
  14. package/dist/src/agents/external-channel-instructions.js +94 -11
  15. package/dist/src/agents/gemini.js +180 -1
  16. package/dist/src/agents/manager.d.ts +2 -0
  17. package/dist/src/agents/manager.js +250 -18
  18. package/dist/src/agents/model-registry/cache.js +37 -1
  19. package/dist/src/agents/model-registry/discovery.js +188 -2
  20. package/dist/src/agents/model-registry/parsers.js +521 -4
  21. package/dist/src/agents/model-registry/runner.js +30 -1
  22. package/dist/src/agents/model-registry/service.js +115 -1
  23. package/dist/src/agents/model-registry/types.js +8 -1
  24. package/dist/src/agents/model-registry.js +19 -1
  25. package/dist/src/agents/native-capabilities.js +237 -1
  26. package/dist/src/agents/openclaw.js +293 -2
  27. package/dist/src/agents/opencode-managed-permissions.js +187 -2
  28. package/dist/src/agents/opencode.js +298 -1
  29. package/dist/src/agents/platform-instructions.js +46 -17
  30. package/dist/src/agents/runtime-compatibility.js +44 -1
  31. package/dist/src/agents/workbuddy-managed-permissions.js +329 -2
  32. package/dist/src/agents/workbuddy.js +444 -3
  33. package/dist/src/commands/room.js +2 -2
  34. package/dist/src/fs/boundary.js +185 -1
  35. package/dist/src/fs/handler.js +130 -1
  36. package/dist/src/fs/security.js +32 -1
  37. package/dist/src/fs/text-decoder.js +110 -1
  38. package/dist/src/index.js +587 -3
  39. package/dist/src/manager/prompt.js +34 -29
  40. package/dist/src/manager/registry.js +317 -1
  41. package/dist/src/manager/retired-external-channel-compat.js +40 -1
  42. package/dist/src/manager/runtime.d.ts +4 -0
  43. package/dist/src/manager/runtime.js +1514 -23
  44. package/dist/src/native-fusion/codex-parser.js +660 -4
  45. package/dist/src/native-fusion/codex-user-input.js +102 -5
  46. package/dist/src/native-fusion/config.js +5 -1
  47. package/dist/src/native-fusion/file-watcher.js +191 -1
  48. package/dist/src/native-fusion/local-repository.js +569 -2
  49. package/dist/src/native-fusion/opencode-parser.js +130 -3
  50. package/dist/src/native-fusion/parsers.js +140 -3
  51. package/dist/src/native-fusion/service.js +739 -2
  52. package/dist/src/native-fusion/session-header.js +101 -2
  53. package/dist/src/native-fusion/state.js +104 -2
  54. package/dist/src/native-fusion/types.js +1 -1
  55. package/dist/src/personal-sync/machine-authority.js +34 -1
  56. package/dist/src/room/activation-audit.js +45 -2
  57. package/dist/src/room/activation-policy.js +30 -1
  58. package/dist/src/room/activation-service.d.ts +1 -0
  59. package/dist/src/room/activation-service.js +408 -8
  60. package/dist/src/room/invite-bootstrap.js +156 -1
  61. package/dist/src/room/managed-context.d.ts +1 -1
  62. package/dist/src/room/managed-context.js +1 -1
  63. package/dist/src/room/managed-prompt-composer.d.ts +1 -1
  64. package/dist/src/room/managed-prompt-composer.js +6 -5
  65. package/dist/src/room/managed-room-binding.js +167 -1
  66. package/dist/src/room/managed-room-ipc.d.ts +2 -2
  67. package/dist/src/room/managed-room-ipc.js +2 -2
  68. package/dist/src/room/mirror-sync-service.js +183 -1
  69. package/dist/src/room/room-action-client.d.ts +11 -0
  70. package/dist/src/room/room-action-client.js +1 -1
  71. package/dist/src/room/room-binding-client.d.ts +1 -0
  72. package/dist/src/room/room-binding-client.js +151 -1
  73. package/dist/src/security/managed-agent-policy.d.ts +3 -2
  74. package/dist/src/security/managed-agent-policy.js +1 -1
  75. package/dist/src/session/archive-zip.js +220 -1
  76. package/dist/src/session/deletion-sync-state.js +136 -2
  77. package/dist/src/session/handlers/agent-capabilities.js +14 -1
  78. package/dist/src/session/handlers/agent-config.js +150 -1
  79. package/dist/src/session/handlers/agents.js +55 -1
  80. package/dist/src/session/handlers/chat.d.ts +1 -1
  81. package/dist/src/session/handlers/chat.js +1030 -2
  82. package/dist/src/session/handlers/control.js +61 -1
  83. package/dist/src/session/handlers/fs.js +594 -1
  84. package/dist/src/session/handlers/message-status.js +15 -1
  85. package/dist/src/session/handlers/remote-access.js +43 -1
  86. package/dist/src/session/handlers/room-managed.js +101 -1
  87. package/dist/src/session/handlers/session-refresh.js +35 -1
  88. package/dist/src/session/handlers/title.js +79 -1
  89. package/dist/src/session/handlers/tool-detail.js +149 -1
  90. package/dist/src/session/handlers/voice-window.js +62 -1
  91. package/dist/src/session/history-subscriptions.js +470 -1
  92. package/dist/src/session/manager.js +658 -1
  93. package/dist/src/session/native-cleanup-outbox.js +287 -2
  94. package/dist/src/session/native-session-lifecycle.js +327 -6
  95. package/dist/src/session/queue.d.ts +5 -0
  96. package/dist/src/session/queue.js +8 -8
  97. package/dist/src/upgrade/scheduler.js +126 -1
  98. package/node_modules/@shennian/wire/dist/agent-workspace.js +1 -1
  99. package/package.json +1 -1
@@ -1,2 +1,739 @@
1
- import B from"node:fs";import O from"node:crypto";import{publishPendingSessionIndexes as k}from"../session/index-publisher.js";import{NativeFusionFileWatcher as R}from"./file-watcher.js";import{NativeEventIdentityConflictError as q,NativeFusionLocalRepository as L}from"./local-repository.js";import{listClaudeTranscriptFiles as D,listCodexRolloutFiles as W,listOpenCodeSessionFiles as $,listNativeTranscriptRoots as z,inspectNativeSessionFile as U,lookupCodexThreadName as H,parseClaudeTranscriptChunk as V,parseCodexRolloutChunk as Q,parseOpenCodeSessionFile as Y}from"./parsers.js";import{MAX_JSONL_SCAN_BYTES_PER_PASS as X}from"./parser-common.js";import{loadNativeScannerState as j,saveNativeScannerState as J}from"./state.js";const G=5*6e4,Z=2*6e4,ee=30*6e4,se=2e3,te=1,F=16,ie=3e4,N=240*6e4;function ne(d){return d.replace(/\r\n/g,`
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
+ }