@awak-app/simy-cli 0.7.46 → 0.7.48

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.
@@ -1,3 +1,8 @@
1
+ import {sendSessionGuidance, reconcileSessionGuidance, observeSessionGuidanceConnection} from './session-supervision/session-guidance.js';
2
+ import {guidanceAllowed, guidanceTarget, guidancePending} from './codex-plugins/simy/native/guidance-intent.mjs';
3
+ import { createClaudeStreamOwner } from './session-supervision/claude-stream-owner.js';
4
+ import { claudeStreamPresentation } from './session-supervision/claude-stream-presentation.js';
5
+ import { readOfficialNativeAccount, pairedNativeIdentity } from './native-provider-identity.js';
1
6
  import { createDesktopFrameWriter } from './desktop-frame-writer.js';
2
7
  import { createDesktopCatalogEncoder, createDesktopStatusSnapshot, copyOwnedDesktopStatus } from './desktop-catalog-frames.js';
3
8
  import { boundedDiscovery } from './session-supervision/progressive-discovery.js';
@@ -43,7 +48,7 @@ import { stopDesktopWorkForUpdate } from './desktop-update-stop.js';
43
48
  import { desktopAccessReady, desktopActionAllowed, requireDesktopAccess, validDesktopAccessPath } from './desktop-access.js';
44
49
  import { createActionDeadlineService } from './session-supervision/action-deadline.js';
45
50
  import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
46
- import { controlDelivery, verifyDelivery, decideManualAcceptance, createRecordReader, readRecord, updateRecord, recordProviderArchive, TERMINAL } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
51
+ import { controlDelivery, verifyDelivery, decideManualAcceptance, createRecordReader, readRecord, updateRecord, recordProviderArchive, processIdentity, TERMINAL } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
47
52
  import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
48
53
  import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
49
54
  import { createStopNotifier } from "./session-supervision/stop-notifications.js";
@@ -167,7 +172,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
167
172
  const frames = createDesktopFrameWriter(output, { capture: frame => frame,
168
173
  encode: frame => typeof frame === 'string' ? frame : catalogEncoder.encode(frame) });
169
174
  const pendingRemoteReceipts = new Map();
170
- let operationJournal, ownerTransport;
175
+ const guidanceReads = new Map(), guidanceReadAt = new Map(), guidanceProbes = new Map();
176
+ let guidanceAbort = new AbortController();
177
+ let operationJournal, ownerTransport, claudeOwner;
178
+ const claudeOwnerStates = new Map();
171
179
  // An explicitly requested window update releases only its viewer. The same
172
180
  // authenticated owner, tasks and pending permissions remain alive on IPC.
173
181
  let windowUpdatePrepared = false, viewerDetached = false, relayStatus = null;
@@ -209,6 +217,16 @@ export async function startDesktopHost({ input = process.stdin, output = process
209
217
  const emitStatus = status => {
210
218
  let catalogObservation;
211
219
  status = projectCatalogFrame(status);
220
+ const claudeScope = agent?.getTeamThreadsScope?.();
221
+ if (desktopAccessReady(status.access) && providerLinks.claude?.connected && claudeScope?.authenticated && status.sessionSupervision?.sessions) {
222
+ const known = new Set(status.sessionSupervision.sessions.filter(row => row.provider === 'claude').map(row => row.session));
223
+ const owners = [...claudeOwnerStates.values()].filter(owner => owner.scope === claudeScope.fenceKey && owner.epoch === (providerLinks.claude.epoch ?? 'legacy-connected'));
224
+ const added = owners.filter(owner => !known.has(owner.session)).map(owner => ({ provider: 'claude', session: owner.session, hostId: 'local',
225
+ cwd: owner.cwd, title: 'Connected Claude conversation', model: owner.model, source: 'simy_claude_stream', isRunning: owner.state !== 'idle' && owner.state !== 'closed',
226
+ autoContinue: false, capabilities: { canMonitor: false, canAutoContinue: false }, conversationPresentation: {} }));
227
+ status = { ...status, sessionSupervision: { ...status.sessionSupervision, sessions: [...status.sessionSupervision.sessions, ...added]
228
+ .map(row => claudeStreamPresentation(row, owners.find(owner => owner.session === row.session))) } };
229
+ }
212
230
  status = { ...status, windowUpdate: { version: 1, preservesWork: Boolean(ownerTransport), prepared: windowUpdatePrepared } };
213
231
  const updateSettings = updater?.snapshot?.() || {};
214
232
  status = { ...status, update: { ...status.update,
@@ -295,7 +313,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
295
313
  ...(dependencies.observeUpdateProvider ? {observe:dependencies.observeUpdateProvider} : {}) });
296
314
  const coordinator = createUpdateCoordinator({ ...(dependencies.updateDetachment || {
297
315
  store:updateRuntime.store,gate:updateRuntime.gate,
298
- boundary:dependencies.updateSafeBoundary || (async()=>{const holds=updater?.updateDrainState?.();if(holds && (holds.activeWork || holds.applyHolds))return {safe:false,liveExecutions:holds.activeWork,pendingWrites:holds.applyHolds,unsettledPermissions:0};return updateRuntime.boundary();}),
316
+ boundary:dependencies.updateSafeBoundary || (async()=>{if(claudeOwner?.busy())return {safe:false,liveExecutions:1,pendingWrites:0,unsettledPermissions:1};const holds=updater?.updateDrainState?.();if(holds && (holds.activeWork || holds.applyHolds))return {safe:false,liveExecutions:holds.activeWork,pendingWrites:holds.applyHolds,unsettledPermissions:0};return updateRuntime.boundary();}),
299
317
  reconcile:dependencies.reconcileUpdateConnection || createCoordinatorReconciler(dependencies.updateReconciliation) || updateRuntime.reconcile,
300
318
  }), preserveConnectedOnStartup:true, initialState:env.SIMY_UPDATE_TRANSFER_TOKEN?'detached':'connected',
301
319
  onChange:()=>{if(agent && !closed && !relay)setImmediate(()=>void publish().catch(()=>{}));} });
@@ -335,6 +353,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
335
353
  const previouslyReady = desktopAccessReady(currentAccess);
336
354
  currentAccess = access || { state: 'unavailable', organizationId: null, organizationName: null };
337
355
  if (previousScope !== accessScope(currentAccess) || previouslyReady && !desktopAccessReady(currentAccess)) {
356
+ // revoke fences new writes synchronously. Cleanup must not be awaited
357
+ // from authorization: that refresh may belong to the owner operation
358
+ // currently holding the cleanup queue.
359
+ void claudeOwner?.revoke?.().catch(error => diagnostics.write(`Claude owner cleanup retained: ${error?.code || 'unknown'}`));
360
+ guidanceAbort.abort(); guidanceAbort = new AbortController();
338
361
  updateIdentityRevision++;
339
362
  // A privilege loss also invalidates consent reads, even when the
340
363
  // credential/organization revision itself did not change.
@@ -592,6 +615,22 @@ export async function startDesktopHost({ input = process.stdin, output = process
592
615
  if (expectedScope !== undefined && expectedScope !== scope) throw Object.assign(new Error("Desktop account or organization changed."), { code: "desktop_access_required" });
593
616
  return scope;
594
617
  };
618
+ claudeOwner = (dependencies.createClaudeStreamOwner || createClaudeStreamOwner)({ root,
619
+ inspectProcess: dependencies.processIdentity || processIdentity,
620
+ executable: async () => { const native = await (dependencies.inspectBackendExecutable || inspectBackendExecutable)('claude');
621
+ if (!native?.compatible || !native.executable) throw Object.assign(new Error('Claude Code is unavailable.'), { code: 'claude_stream_unavailable' });
622
+ return native.executable; },
623
+ authorize: () => authorizeProvider('claude'),
624
+ context: async () => {
625
+ const scope = agent?.getTeamThreadsScope?.();
626
+ if (!scope?.authenticated || !scope.allowed || !scope.fenceKey) throw Object.assign(new Error('Sign in to SIMY first.'), { code: 'desktop_access_required' });
627
+ const link = await requireProviderLink({ root: deliveryRoot, provider: 'claude' });
628
+ const email = await (dependencies.readOfficialNativeAccount || readOfficialNativeAccount)('claude');
629
+ if (!email) throw Object.assign(new Error('Sign in to Claude Code first.'), { code: 'native_identity_unavailable' });
630
+ return { scope: scope.fenceKey, epoch: link.epoch ?? 'legacy-connected',
631
+ account: pairedNativeIdentity({ deviceId: scope.fenceKey, provider: 'claude', email, connectionEpoch: link.epoch }).account_id };
632
+ },
633
+ onChange: owner => { claudeOwnerStates.set(owner.session, owner); if (!closed && agent) setImmediate(() => { void publish().catch(() => {}); }); } });
595
634
  const selectedScopes = new WeakMap(), selectedLinkEpochs = new WeakMap();
596
635
  const guardedEnroll = async options => {
597
636
  await authorizeProvider(options.provider);
@@ -618,7 +657,54 @@ export async function startDesktopHost({ input = process.stdin, output = process
618
657
  let catalogAnalysisJob = null, catalogAnalysisAgain = false;
619
658
  const withCurrentSettings = (sessions, records) => {
620
659
  const byKey = new Map(records.map(record => [`${record.provider}:${record.session}`, record]));
621
- return sessions.map(candidate => projectSessionAutoContinue(candidate, byKey.get(`${candidate.provider}:${candidate.session}`)));
660
+ return sessions.map(candidate => {
661
+ const record = byKey.get(`${candidate.provider}:${candidate.session}`);
662
+ const projected = projectSessionAutoContinue(candidate, record);
663
+ // An authenticated native Stop can be newer than the catalog's running
664
+ // observation while history is loading. It wakes read-only analysis;
665
+ // fresh context and the native writer lock still gate actual dispatch.
666
+ if (candidate.provider === 'codex' && projected.autoContinue === true && typeof candidate.cwd === 'string' && candidate.cwd && record?.cwd === candidate.cwd &&
667
+ record.lastEvent === 'Stop' && record.runtime?.isRunning === false && record.runtime.process?.identity &&
668
+ Number.isFinite(record.runtime.observedAt) && Number.isFinite(candidate.observation?.observedAt) &&
669
+ record.runtime.observedAt > candidate.observation.observedAt) return { ...projected, isRunning: false,
670
+ observation: { ...candidate.observation, observedAt: record.runtime.observedAt } };
671
+ return projected;
672
+ });
673
+ };
674
+ const automaticCandidates = discovery => discovery.sessions.filter(candidate => {
675
+ if (discovery.completeProviders?.includes(candidate.provider)) return true;
676
+ // A newly read exact Codex conversation need not wait for unrelated
677
+ // history pages. Context, consent, native permissions and the writer
678
+ // lock are still checked by the existing enrollment/dispatch path.
679
+ const admission = candidate.continuationAdmission;
680
+ return candidate.provider === 'codex' && discovery.providers?.codex?.discovery === 'ready' &&
681
+ candidate.observation?.source === 'codex_app_server' && admission?.source === 'codex_app_server' &&
682
+ admission.provider === candidate.provider && admission.session === candidate.session &&
683
+ admission.cwd === candidate.cwd && typeof admission.model === 'string' && admission.model.length > 0 &&
684
+ Number.isFinite(admission.validatedAt) && Number.isFinite(discovery.discoveryAttempt?.requestedAt) &&
685
+ admission.validatedAt >= discovery.discoveryAttempt.requestedAt;
686
+ });
687
+ let automaticRefresh = null, automaticRefreshAgain = false;
688
+ // Execution scheduling must not wait for optional Action/deadline projection.
689
+ // The coordinator still owns fresh context, opt-in, authority and writer checks.
690
+ const refreshAutomaticContinuation = () => {
691
+ automaticRefreshAgain = true;
692
+ if (automaticRefresh) return automaticRefresh;
693
+ const scope = accessScope(currentAccess), controller = automaticContinuation;
694
+ automaticRefresh = Promise.resolve().then(async () => {
695
+ while (automaticRefreshAgain) {
696
+ automaticRefreshAgain = false;
697
+ const records = await recordReader.list();
698
+ if (closed || relay || discoveryStopped || controller !== automaticContinuation ||
699
+ !desktopAccessReady(currentAccess) || accessScope(currentAccess) !== scope || !coordinator.acceptsNewWork()) return;
700
+ controller?.tick(automaticCandidates({ ...sessionStatus,
701
+ sessions: withCurrentSettings(sessionStatus.sessions, records) }));
702
+ }
703
+ }).catch(() => {}).finally(() => {
704
+ automaticRefresh = null;
705
+ if (automaticRefreshAgain && !closed && !relay && !discoveryStopped) void refreshAutomaticContinuation();
706
+ });
707
+ return automaticRefresh;
622
708
  };
623
709
  const refreshCatalogAnalysis = () => {
624
710
  catalogAnalysisAgain = true;
@@ -633,6 +719,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
633
719
  await manager.update(analysisSessions(withCurrentSettings(sessionStatus.sessions, records)), {
634
720
  priorityKeys: new Set(records.filter(supervisedSession).map(record => `${record.provider}:${record.session}`)),
635
721
  });
722
+ void refreshAutomaticContinuation();
636
723
  }
637
724
  }).catch(() => {}).finally(() => {
638
725
  catalogAnalysisJob = null;
@@ -809,7 +896,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
809
896
  candidate = sessionStatus.sessions.find(matches);
810
897
  }
811
898
  if (!candidate) throw new Error('This conversation is unavailable; refresh its provider.');
812
- candidate = projectSessionAutoContinue(candidate, await readRecord(deliveryRoot, command.provider, command.session));
899
+ const selectedRecord = await readRecord(deliveryRoot, command.provider, command.session);
900
+ candidate = withCurrentSettings([candidate], selectedRecord ? [selectedRecord] : [])[0];
813
901
  await authorizeProvider(command.provider, scope, link.epoch);
814
902
  const selected = { ...candidate };
815
903
  selectedScopes.set(selected, scope);
@@ -820,6 +908,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
820
908
  if (!PROVIDER_LINKS.includes(provider)) throw new Error("Unsupported SIMY provider connection.");
821
909
  if (providerLinkBusy.has(provider)) throw new Error("SIMY connection is already changing.");
822
910
  providerRepairControllers.get(provider)?.abort();
911
+ guidanceAbort.abort(); guidanceAbort = new AbortController();
823
912
  providerLinkBusy.add(provider);
824
913
  providerLinkFeedback[provider] = { state: connected ? "connecting" : "disconnecting" };
825
914
  let phase = "read_settings";
@@ -827,6 +916,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
827
916
  providerLinks = await readProviderLinks(root);
828
917
  phase = "save_disconnect";
829
918
  if (!connected) providerLinks = await writeProviderLink(root, provider, false);
919
+ if (provider === 'claude' && !connected) await claudeOwner.revoke();
830
920
  if (activeStatisticsRead?.key.startsWith(`${provider}:`)) activeStatisticsRead.controller.abort();
831
921
  discoveryController?.abort(); enrichmentController?.abort();
832
922
  sessionRefreshAgain = false;
@@ -958,9 +1048,12 @@ export async function startDesktopHost({ input = process.stdin, output = process
958
1048
  return (dependencies.analyzeContinuation || analyzeContinuation)(candidate, { ...options,
959
1049
  beforeProviderDispatch: () => authorizeProvider(candidate.provider, scope, link.epoch) });
960
1050
  },
961
- onChange: () => { if (!closed && !relay) setImmediate(() => { void publish().catch(() => {}); }); },
1051
+ onChange: () => { if (!closed && !relay) {
1052
+ void refreshAutomaticContinuation();
1053
+ setImmediate(() => { void publish().catch(() => {}); });
1054
+ } },
962
1055
  });
963
- // Batch burst notifications and maintenance completions into one frame. The
1056
+ // Batch burst notifications and heartbeat/status requests into one frame. The
964
1057
  // short window leaves commands responsive and the regular heartbeat intact.
965
1058
  const publish = () => {
966
1059
  if (closed || relay || !agent) return Promise.resolve();
@@ -985,6 +1078,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
985
1078
  const sampledAccess = structuredClone(status.access || { state: 'unavailable', organizationId: null, organizationName: null });
986
1079
  if (!await acceptStatusAccess(sampledAccess)) { publishAgain = true; return; }
987
1080
  status.access = sampledAccess;
1081
+ const nativeScope = agent.getTeamThreadsScope?.();
1082
+ if (desktopAccessReady(sampledAccess) && providerLinks.claude?.connected && nativeScope?.authenticated && nativeScope.allowed &&
1083
+ nativeScope.fenceKey && claudeLoadedScope !== nativeScope.fenceKey) {
1084
+ try {
1085
+ for (const owner of await claudeOwner.list()) claudeOwnerStates.set(owner.session, owner);
1086
+ claudeLoadedScope = nativeScope.fenceKey;
1087
+ } catch { /* The connection stays unavailable until native identity is verified. */ }
1088
+ }
988
1089
  status.updateDetachment = updateRuntime.snapshot(coordinator.snapshot(),updater?.snapshot?.(),{accessReady:desktopAccessReady(sampledAccess)});
989
1090
  preferenceScope = controllerScope(status);
990
1091
  if (preferenceScope) await controllerPreferences.load(preferenceScope);
@@ -1062,6 +1163,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1062
1163
  }).finally(() => { connectionPublishing = null; });
1063
1164
  return connectionPublishing;
1064
1165
  };
1166
+ let claudeLoadedScope = null;
1065
1167
  const projectStatus = () => {
1066
1168
  if (closed || relay || !agent) return Promise.resolve();
1067
1169
  if (publishing) { publishAgain = true; return publishConnectionStatus(); }
@@ -1079,6 +1181,22 @@ export async function startDesktopHost({ input = process.stdin, output = process
1079
1181
  if (preferenceScope) await controllerPreferences.load(preferenceScope);
1080
1182
  const requestedLinks = await decorateProviderSettings(status);
1081
1183
  const allRecords = await recordReader.list();
1184
+ if (desktopAccessReady(currentAccess) && coordinator.acceptsNewWork()) {
1185
+ for (const record of allRecords) {
1186
+ if (guidanceReads.size >= 2) break;
1187
+ if (!guidancePending(record) || !record.run.managed.userGuidance.bridgeAttemptedAt || guidanceReads.has(record.session) ||
1188
+ Date.now() - (guidanceReadAt.get(record.session) || 0) < 15_000) continue;
1189
+ guidanceReadAt.set(record.session, Date.now());
1190
+ const scope = accessScope(currentAccess);
1191
+ const authorize = async () => {
1192
+ if (closed || !coordinator.acceptsNewWork() || accessScope(currentAccess) !== scope) throw new Error('Guidance read scope changed.');
1193
+ await authorizeProvider(record.provider, scope);
1194
+ };
1195
+ const work = reconcileSessionGuidance({root: deliveryRoot, record, authorize, signal: guidanceAbort.signal}).then(changed => {if (changed) void publish();})
1196
+ .catch(() => {}).finally(() => guidanceReads.delete(record.session));
1197
+ guidanceReads.set(record.session, work);
1198
+ }
1199
+ }
1082
1200
  if (accessScope(currentAccess) === requestedScope) {
1083
1201
  // Keep explicit preference overrides until the scope changes. A full
1084
1202
  // projection that started before the save can finish after this read.
@@ -1153,7 +1271,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1153
1271
  await actionAssociations.decorate(status.deliveryHooks, projection);
1154
1272
  const catalog = refreshSessions();
1155
1273
  const discovery = { ...catalog, sessions: withCurrentSettings(catalog.sessions, records) };
1156
- if (coordinator.acceptsNewWork()) automaticContinuation?.tick(discovery.sessions.filter(candidate => Array.isArray(discovery.completeProviders) && discovery.completeProviders.includes(candidate.provider)));
1274
+
1157
1275
  let sessions = discovery.sessions.map(candidate => automaticContinuation.project(analysisManager.project(candidate)));
1158
1276
  await analysisManager.observeContinuationWaits?.(sessions);
1159
1277
  sessions = sessions.map(candidate => analysisManager.projectContinuationWait?.(candidate) || candidate);
@@ -1254,7 +1372,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1254
1372
  const results = await Promise.allSettled([stopDiscovery({ drain: true }), publishing, connectionPublishing,
1255
1373
  pluginInspection,
1256
1374
  ...providerRepairJobs,
1257
- controllerPreferences.close(), operationJournal.close(), remoteHosts?.close(), remoteMetadata.close(), actionAssociations.close(), automaticContinuation?.close(), analysisManager?.close(),
1375
+ claudeOwner.close(), controllerPreferences.close(), operationJournal.close(), remoteHosts?.close(), remoteMetadata.close(), actionAssociations.close(), automaticContinuation?.close(), analysisManager?.close(),
1258
1376
  managedSupervisor.close(), recordReader.close(), stopWorkflows(),
1259
1377
  agent ? createAgentShutdown(agent)() : undefined]);
1260
1378
  const failure = results.find(result => result.status === "rejected");
@@ -1262,6 +1380,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1262
1380
  };
1263
1381
  const close = () => shuttingDown ||= (async () => {
1264
1382
  closed = true;
1383
+ guidanceAbort.abort();
1265
1384
  frames.close();
1266
1385
  commands?.close();
1267
1386
  input.pause?.();
@@ -1425,7 +1544,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
1425
1544
  const maintenance = new Map();
1426
1545
  const tick = worker => {
1427
1546
  if (closed || relay || !coordinator.acceptsNewWork() || maintenance.has(worker)) return;
1428
- const pending = Promise.resolve().then(() => worker.tick()).then(() => { if (!closed && !relay) return publish(); }).catch(() => {}).finally(() => maintenance.delete(worker));
1547
+ // The existing heartbeat publishes completed background maintenance.
1548
+ // Completion itself does not prove a visible change; after the short
1549
+ // batching window it otherwise reprojects/repaints the entire history.
1550
+ // Explicit commands and actual change notifications keep their live path.
1551
+ const pending = Promise.resolve().then(() => worker.tick()).catch(() => {}).finally(() => maintenance.delete(worker));
1429
1552
  maintenance.set(worker, pending);
1430
1553
  };
1431
1554
  tick(actionAssociations);
@@ -1435,6 +1558,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
1435
1558
  if (desktopAccessReady(currentAccess)) {
1436
1559
  tick(actionAssociations);
1437
1560
  tick(managedSupervisor);
1561
+ // Observe native Stop records even when the full status projection or
1562
+ // unrelated provider history is still pending.
1563
+ void refreshCatalogAnalysis();
1438
1564
  }
1439
1565
  void publish().catch(() => emit({ type: "error", message: "SIMY status could not be refreshed." }));
1440
1566
  }, 3_000);
@@ -1499,6 +1625,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1499
1625
  }
1500
1626
  };
1501
1627
  const executeUpdate = (prepareRestart = false) => {
1628
+ if (claudeOwner.busy()) throw new Error('Stop the connected Claude turn or answer its permission request before updating.');
1502
1629
  const kind = prepareRestart ? 'restart' : 'install';
1503
1630
  if (updateOperation && updateOperationKind !== kind) throw new Error('Another update operation is in progress. Retry after it finishes.');
1504
1631
  if (updateOperation) return updateOperation;
@@ -1596,6 +1723,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1596
1723
  await publish(); return;
1597
1724
  }
1598
1725
  if (action === "quit") {
1726
+ if (claudeOwner.busy()) throw new Error('Stop the connected Claude turn or answer its permission request before quitting.');
1599
1727
  if (command.forUpdate === true) {
1600
1728
  if (command.force !== true) throw new Error('Confirm before stopping unfinished tasks.');
1601
1729
  await stopWorkForUpdate();
@@ -1850,7 +1978,23 @@ export async function startDesktopHost({ input = process.stdin, output = process
1850
1978
  await authorizeProvider(command.provider, commandScope, commandProviderLink?.epoch);
1851
1979
  const cached = lastStatus?.sessionSupervision?.sessions?.find(item =>
1852
1980
  item.provider === command.provider && item.session === command.session && (!item.hostId || item.hostId === 'local'));
1853
- const record = await readRecord(deliveryRoot, command.provider, command.session);
1981
+ let record = await readRecord(deliveryRoot, command.provider, command.session);
1982
+ if (guidanceAllowed(record)) {
1983
+ const observation = record.run.managed.nativeConnectionObservation;
1984
+ const age = Date.now() - observation?.at;
1985
+ const key = JSON.stringify([commandScope, guidanceTarget(record)]);
1986
+ let probe = guidanceProbes.get(key);
1987
+ if (!probe && !(age >= 0 && age < 15_000) && guidanceProbes.size < 2) {
1988
+ const authorize = () => authorizeProvider(command.provider, commandScope, commandProviderLink?.epoch);
1989
+ const signal = AbortSignal.any([guidanceAbort.signal, AbortSignal.timeout(20_000)]);
1990
+ probe = (dependencies.observeSessionGuidanceConnection || observeSessionGuidanceConnection)({root: deliveryRoot,
1991
+ record, signal, authorize}).finally(() => guidanceProbes.delete(key));
1992
+ guidanceProbes.set(key, probe);
1993
+ }
1994
+ if (probe) await probe;
1995
+ record = await readRecord(deliveryRoot, command.provider, command.session);
1996
+ if (frameRecordScope === commandScope) frameRecords.set(`${command.provider}:${command.session}`, record);
1997
+ }
1854
1998
  const observed = sessionStatus.sessions.find(item => item.provider === command.provider && item.session === command.session);
1855
1999
  if (!record && !observed) throw new Error('This conversation status is unavailable; refresh its provider.');
1856
2000
  // Read one durable task without scheduling a complete provider scan
@@ -1864,13 +2008,16 @@ export async function startDesktopHost({ input = process.stdin, output = process
1864
2008
  const selectedRun = controls.runs?.find(run => run.provider === command.provider && run.session === command.session);
1865
2009
  row.decisionRequest = sessionDecisionPresentation(row, selectedRun);
1866
2010
  row.conversationPresentation = conversationPresentation(row, selectedRun, { rawRecord: record });
2011
+ const owner = claudeOwnerStates.get(command.session);
2012
+ if (owner?.scope === agent.getTeamThreadsScope?.()?.fenceKey) Object.assign(row, claudeStreamPresentation(row, owner));
1867
2013
  await authorizeProvider(command.provider, commandScope, commandProviderLink?.epoch);
1868
2014
  // The same app projection carries observation time and next action;
1869
2015
  // saved consent and an active record cannot prove a provider send.
1870
2016
  const { provider, session, title, autoContinue, autoContinueStatus, autoContinueReason,
1871
- binding, workState, dispatchReadiness, conversationPresentation: presentation, decisionRequest } = row;
2017
+ binding, workState, dispatchReadiness, conversationPresentation: presentation, decisionRequest, claudeConnection, originalSessionTarget } = row;
1872
2018
  emit({ type: 'result', id: command.id, ok: true, data: { provider, session, title,
1873
- autoContinue, autoContinueStatus, autoContinueReason, binding, workState, dispatchReadiness, conversationPresentation: presentation, decisionRequest } });
2019
+ autoContinue, autoContinueStatus, autoContinueReason, binding, workState, dispatchReadiness, conversationPresentation: presentation, decisionRequest, claudeConnection, originalSessionTarget } });
2020
+ void publish().catch(() => {});
1874
2021
  return;
1875
2022
  }
1876
2023
  if (action === 'session_read_context') {
@@ -2347,6 +2494,30 @@ export async function startDesktopHost({ input = process.stdin, output = process
2347
2494
  await (dependencies.monitorSession || monitorSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
2348
2495
  session: command.session, monitor: action === "session_monitor", expectedProviderEpoch: commandProviderLink?.epoch });
2349
2496
  await refreshSessions(true);
2497
+ } else if (['claude_connection_create', 'claude_connection_connect', 'claude_connection_disconnect', 'claude_connection_stop', 'claude_permission_decide'].includes(action)) {
2498
+ if (command.hostId && command.hostId !== 'local' || command.provider !== 'claude') throw new Error('Connected Claude requires this local host.');
2499
+ await authorizeProvider('claude', commandScope, commandProviderLink?.epoch);
2500
+ const data = action === 'claude_connection_create' ? await claudeOwner.create({ cwd: command.cwd, model: command.model,
2501
+ permissionMode: command.permissionMode, confirmedNew: command.confirmedNew }) : action === 'claude_connection_connect' ? await claudeOwner.connect(command)
2502
+ : action === 'claude_connection_disconnect' ? await claudeOwner.disconnect(command) : action === 'claude_connection_stop' ? await claudeOwner.stop(command, { authorize: () => authorizeWork(commandScope) }) : await claudeOwner.decide(command);
2503
+ claudeOwnerStates.set(data.session, data);
2504
+ emit({ type: 'result', id: command.id, ok: true, data }); await publish(); return;
2505
+ } else if (action === 'session_guidance' && command.provider === 'claude') {
2506
+ if (command.hostId && command.hostId !== 'local') throw new Error('Direct guidance requires the original local host.');
2507
+ await authorizeProvider('claude', commandScope, commandProviderLink?.epoch);
2508
+ const data = await claudeOwner.send(command);
2509
+ emit({ type: 'result', id: command.id, ok: true, data }); await publish(); return;
2510
+ } else if (action === 'session_guidance') {
2511
+ if (command.hostId && command.hostId !== 'local') throw new Error('Direct guidance requires the original local host.');
2512
+ const candidate = await selectedSession(command);
2513
+ if (candidate.provider !== 'codex' || candidate.isRunning !== false) throw new Error('The original conversation must be idle before sending guidance.');
2514
+ const data = await (dependencies.sendSessionGuidance || sendSessionGuidance)({root: deliveryRoot,
2515
+ provider: command.provider, session: command.session, requestId: command.requestId,
2516
+ expectedRunId: command.expectedRunId, taskId: command.taskId, expectedRevision: command.expectedRevision,
2517
+ expectedEpoch: command.expectedEpoch, text: command.text, signal: guidanceAbort.signal,
2518
+ authorize: () => authorizeProvider(candidate.provider, commandScope, selectedLinkEpochs.get(candidate))});
2519
+ emit({type: 'result', id: command.id, ok: true, data});
2520
+ await publish(); return;
2350
2521
  } else if (action === "session_followup") {
2351
2522
  const candidate = await selectedSession(command);
2352
2523
  if (!candidate.followup?.previousRunId || command.previousRunId !== candidate.followup.previousRunId) {
@@ -2465,6 +2636,7 @@ function sessionActionError(error, action) {
2465
2636
  if (/workspace changed|metadata.*unavailable|session.*changed/i.test(message)) return "The conversation identity or workspace changed. Refresh and select it again.";
2466
2637
  if (/objective|acceptance|autonomy policy|budget/i.test(message)) return message.replace(/[\r\n\x00-\x1f]/g, " ").slice(0, 240);
2467
2638
  if (/question changed|answer is empty|session understanding|context/i.test(message)) return message.replace(/[\r\n\x00-\x1f]/g, " ").slice(0, 240);
2639
+ if (action === 'session_guidance') return 'Guidance acceptance is unconfirmed. Your draft is kept. Check the original conversation before sending again.';
2468
2640
  if (action === "session_answer") return "Answer acceptance is unconfirmed. Check the original conversation before sending again.";
2469
2641
  return "This conversation could not be enrolled. Refresh its provider status and try again.";
2470
2642
  }
@@ -30,7 +30,7 @@ function candidateMetadata(id, metadata, lastActivityAt, observedAt) {
30
30
  provider: 'claude', session: id, ...(metadata.parentSessionId ? { parentSession: { provider: 'claude', session: metadata.parentSessionId } } : {}), cwd: metadata.cwd, title: metadata.title,
31
31
  source: 'claude_cli', lastActivityAt, createdAt: metadata.createdAt ?? null,
32
32
  observation: { source: 'claude_local_index', observedAt },
33
- availability: 'unsupported', model: null,
33
+ availability: 'unsupported', model: null, observedModel: metadata.observedModel || null,
34
34
  capabilities: { canMonitor: true, canEnroll: false, canAutoContinue: false },
35
35
  reason: 'Claude session ownership and safe exact-session takeover are unverified; monitoring only.',
36
36
  };
@@ -66,7 +66,7 @@ function metadataFromPrefix(bytes, id) {
66
66
  const text = bytes.toString('utf8');
67
67
  const complete = text.slice(0, text.lastIndexOf('\n') + 1);
68
68
  if (!complete) return null;
69
- let cwd = '', title = '', customTitle = '', parentSessionId = null, found = false, createdAt = null;
69
+ let cwd = '', title = '', customTitle = '', parentSessionId = null, found = false, createdAt = null, observedModel = null;
70
70
  for (const line of complete.split('\n').slice(0, MAX_LINES)) {
71
71
  if (!line) continue;
72
72
  let item;
@@ -83,10 +83,11 @@ function metadataFromPrefix(bytes, id) {
83
83
  if (!cwd) cwd = resolve(item.cwd);
84
84
  // A conversation can cd into a child directory without changing identity.
85
85
  if (!within(cwd, resolve(item.cwd))) return null;
86
+ if (item.type === 'assistant' && /^claude-[a-zA-Z0-9._:-]{1,140}$/.test(item.message?.model || '')) observedModel = item.message.model;
86
87
  if (!title) title = sessionTitle(item.slug);
87
88
  found = true;
88
89
  }
89
- return found ? { cwd, title: customTitle || title, parentSessionId, createdAt } : null;
90
+ return found ? { cwd, title: customTitle || title, parentSessionId, createdAt, observedModel } : null;
90
91
  }
91
92
 
92
93
  // /rename appends a custom-title envelope, often beyond the bounded prefix.
@@ -363,7 +364,7 @@ async function humanBoundaryFromHandle(handle, opened, path, session, cwd, reque
363
364
  if (cursor && (cursor.path !== path || cursor.session !== session || cursor.cwd !== cwd ||
364
365
  !matchesHumanCursorIdentity(cursor, initial) || !Number.isSafeInteger(cursor.size) ||
365
366
  cursor.size <= 0 || opened.size < cursor.size || !/^[a-f0-9]{64}$/.test(cursor.prefixSHA256 || ''))) return null;
366
- const prefixHash = createHash('sha256'), userIds = new Set();
367
+ const prefixHash = createHash('sha256'), userIds = new Set(), appendedUserIds = new Set();
367
368
  const decoder = new TextDecoder('utf-8', { fatal: true });
368
369
  let latest = null, scanned;
369
370
  try {
@@ -383,7 +384,7 @@ async function humanBoundaryFromHandle(handle, opened, path, session, cwd, reque
383
384
  if (item.message?.content?.every?.(part => part?.type === 'tool_result')) return;
384
385
  if (!UUID.test(item.uuid || '') || userIds.has(item.uuid)) return false;
385
386
  userIds.add(item.uuid);
386
- if (!cursor || start >= cursor.size) latest = item;
387
+ if (!cursor || start >= cursor.size) { latest = item; appendedUserIds.add(item.uuid); }
387
388
  } });
388
389
  } catch (error) { if (signal?.aborted) throw error; return null; }
389
390
  if (!scanned || cursor && prefixHash.digest('hex') !== cursor.prefixSHA256) return null;
@@ -393,7 +394,7 @@ async function humanBoundaryFromHandle(handle, opened, path, session, cwd, reque
393
394
  humanFileFingerprint(initial) !== humanFileFingerprint(pathAfter)) return null;
394
395
  if (request.snapshot) return { version: 2, session, cwd, path, dev: String(initial.dev), ino: String(initial.ino),
395
396
  birthtimeNs: String(initial.birthtimeNs), size: opened.size, prefixSHA256: scanned.digest };
396
- if (request.validateCursor) return cursor ? { valid: true } : null;
397
+ if (request.validateCursor) return cursor && (!request.streamOwner || [...appendedUserIds].every(id => id === request.allowedNewUserRef)) ? { valid: true } : null;
397
398
  if (!cursor || !latest || latest.origin?.kind !== 'human' || latest.cwd !== cwd || latest.message?.role !== 'user') return null;
398
399
  const at = Date.parse(latest.timestamp), text = textContent(latest.message.content);
399
400
  if (!Number.isFinite(at) || at < request.notBefore || at > request.now ||
@@ -413,6 +414,12 @@ export async function snapshotClaudeHumanCursor({ session, cwd }, options) {
413
414
  export async function validateClaudeHumanCursor(cursor, options) {
414
415
  return !!cursor && !!await exactHumanTranscript(cursor.session, cursor.cwd, { cursor, validateCursor: true }, options);
415
416
  }
417
+ // An owned stream may append only its currently acknowledged native user UUID.
418
+ // Assistant/progress metadata is allowed; another human writer invalidates it.
419
+ export async function validateClaudeStreamCursor(cursor, allowedNewUserRef = null, options) {
420
+ return !!cursor && !!await exactHumanTranscript(cursor.session, cursor.cwd,
421
+ { cursor, validateCursor: true, streamOwner: true, allowedNewUserRef }, options);
422
+ }
416
423
  export async function readClaudeHumanBoundary({ session, cwd, promptSHA256, promptBytes, notBefore, cursor, now = Date.now() }, options) {
417
424
  if (!/^[a-f0-9]{64}$/.test(promptSHA256 || '') || !Number.isSafeInteger(promptBytes) || promptBytes <= 0 ||
418
425
  !Number.isFinite(notBefore) || !Number.isFinite(now) || notBefore > now || !cursor) return null;
@@ -526,7 +533,7 @@ async function scanNativeRows(path, visit, { signal, expectedDigest } = {}) {
526
533
  if (!before.isFile()) throw new Error('Native transcript unavailable');
527
534
  const decoder = new TextDecoder('utf-8', { fatal: true });
528
535
  const scanned = await scanTranscriptLines(handle, before, { maxBytes: MAX_COMPLETE_TRANSCRIPT_BYTES, signal,
529
- onLine: line => visit(JSON.parse(decoder.decode(line))) });
536
+ onLine: (line, _offset, index) => visit(JSON.parse(decoder.decode(line)), index) });
530
537
  const after = await handle.stat();
531
538
  const pathAfter = await lstat(path);
532
539
  if (await realpath(path) !== path || pathAfter.isSymbolicLink() || pathAfter.ino !== before.ino || pathAfter.dev !== before.dev ||
@@ -542,10 +549,10 @@ async function nativeParentTranscript(candidate, { home, signal }, visit) {
542
549
  .filter(row => row.cwd === candidate.cwd && row.boundary?.complete);
543
550
  if (matches.length !== 1) return null;
544
551
  const parentPath = matches[0].transcriptPath;
545
- const scanned = await scanNativeRows(parentPath, row => {
552
+ const scanned = await scanNativeRows(parentPath, (row, index) => {
546
553
  if (row.sessionId !== undefined && row.sessionId !== candidate.session ||
547
554
  ['user', 'assistant'].includes(row.type) && (row.isSidechain === true || row.cwd !== candidate.cwd)) return false;
548
- return visit(row);
555
+ return visit(row, index);
549
556
  }, { signal, expectedDigest: matches[0].boundary.digest });
550
557
  return { parentPath, ...scanned };
551
558
  }
@@ -561,23 +568,27 @@ export async function readClaudeNativeAgentContract(candidate, { home, signal }
561
568
  export async function readClaudeNativeReview(candidate, request, { home, signal } = {}) {
562
569
  try {
563
570
  const calls = [], contracts = createForegroundAgentContractCollector(candidate);
564
- const parent = await nativeParentTranscript(candidate, { home, signal }, row => {
571
+ const parent = await nativeParentTranscript(candidate, { home, signal }, (row, index) => {
565
572
  if (row.sessionId === candidate.session && !row.isSidechain && row.type === 'assistant' && Date.parse(row.timestamp) >= request.requestedAt) {
566
573
  for (const part of row.message?.content || []) {
567
574
  if (part.type === 'tool_use' && ['Agent', 'Task'].includes(part.name) && part.input?.prompt === request.prompt)
568
- calls.push({ call: part, contract: contracts.finish() });
575
+ calls.push({ call: part, index, contract: contracts.finish() });
569
576
  if (calls.length > 1) return false;
570
577
  }
571
578
  }
572
579
  contracts.add(row);
573
580
  });
574
581
  if (!parent || calls.length !== 1) return null;
575
- const { parentPath } = parent, { call, contract } = calls[0];
582
+ const { parentPath } = parent, { call, index, contract } = calls[0];
576
583
  if (call.input.subagent_type !== request.agentType || !isForegroundNativeReviewInput(call.name, call.input, contract?.schema)) return null;
577
584
  const receipts = [];
578
- const receiptScan = await scanNativeRows(parentPath, row => {
585
+ const receiptScan = await scanNativeRows(parentPath, (row, receiptIndex) => {
579
586
  if (row.sessionId === candidate.session && !row.isSidechain && row.type === 'user' &&
580
- row.message?.content?.some?.(part => part.type === 'tool_result' && part.tool_use_id === call.id) && row.toolUseResult) receipts.push(row);
587
+ row.message?.content?.some?.(part => part.type === 'tool_result' && part.tool_use_id === call.id) && row.toolUseResult) {
588
+ // A result preceding its exact invocation cannot prove it returned.
589
+ if (receiptIndex <= index) return false;
590
+ receipts.push(row);
591
+ }
581
592
  if (receipts.length > 1) return false;
582
593
  }, { signal, expectedDigest: parent.digest });
583
594
  if (receiptScan.snapshot !== parent.snapshot) return null;