@platless/axom-cli 0.6.36 → 0.6.52

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 (159) hide show
  1. package/README.md +7 -1
  2. package/dist/axom-patch/cli/README.md +31 -2
  3. package/dist/axom-patch/cli/account-limits/generate.py +2 -2
  4. package/dist/axom-patch/cli/claude-replay-lifecycle.json +16 -0
  5. package/dist/axom-patch/cli/codex-terminal-output.json +2 -2
  6. package/dist/axom-patch/cli/probes/claude-replay-lifecycle.mjs +31 -0
  7. package/dist/axom-patch/cli/probes/codex-terminal-output.mjs +1 -1
  8. package/dist/axom-patch/cli/probes/zcode-env-account.mjs +81 -0
  9. package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +7 -0
  10. package/dist/axom-patch/cli/qoder/dist/axom-acp-compat.mjs +137 -41
  11. package/dist/axom-patch/cli/qoder/releases/1.1.75.json +5 -88
  12. package/dist/axom-patch/cli/qoder-native-acp.json +23 -23
  13. package/dist/axom-patch/cli/runtime.json +3 -1
  14. package/dist/axom-patch/cli/zcode/env-account.mjs +13 -0
  15. package/dist/axom-patch/cli/zcode/generate-env-account.py +52 -0
  16. package/dist/axom-patch/cli/zcode-account-limits.json +7 -7
  17. package/dist/axom-patch/cli/zcode-env-account.json +58 -0
  18. package/dist/axom-patch/cli/zcode-models.json +9 -9
  19. package/dist/axom-patch/cli/zcode-subagent-subwork.json +10 -10
  20. package/dist/cli.js +9 -11
  21. package/dist/commands/auth.js +4 -1
  22. package/dist/commands/update.js +68 -7
  23. package/dist/commands/workstation.js +94 -223
  24. package/dist/commands/workstationRegistrations.js +116 -0
  25. package/dist/credentialBroker.js +13 -2
  26. package/dist/credentials.js +16 -0
  27. package/dist/install/claude/commands.js +2 -2
  28. package/dist/keepers/terminal/keeper.mjs +1089 -673
  29. package/dist/keepers/terminal/manifest.json +4 -4
  30. package/dist/native/darwin-x64/axom-quic +0 -0
  31. package/dist/workstation/acp/acpAgents.js +11 -4
  32. package/dist/workstation/acp/outbox.js +12 -5
  33. package/dist/workstation/acp/permission.js +2 -2
  34. package/dist/workstation/acp/processor/checkpoint.js +4 -3
  35. package/dist/workstation/acp/processor/control.js +2 -2
  36. package/dist/workstation/acp/processor/jsonrpc.js +8 -0
  37. package/dist/workstation/acp/processor/session.js +3 -2
  38. package/dist/workstation/acp/processor/state.js +1 -0
  39. package/dist/workstation/acp/processor/transport/local.js +19 -3
  40. package/dist/workstation/acp/processor/updates.js +25 -0
  41. package/dist/workstation/acp/processorSession.js +12 -1
  42. package/dist/workstation/acp/registry/catalog.js +6 -6
  43. package/dist/workstation/acp/test/keeperWorld.js +4 -3
  44. package/dist/workstation/agentSessions.js +76 -23
  45. package/dist/workstation/channels/agent.js +9 -1
  46. package/dist/workstation/channels/computer.js +27 -4
  47. package/dist/workstation/channels/management.js +36 -5
  48. package/dist/workstation/config.js +18 -6
  49. package/dist/workstation/control/bindingStore.js +4 -0
  50. package/dist/workstation/control/commands.js +1 -0
  51. package/dist/workstation/control/daemonIdentity.js +90 -0
  52. package/dist/workstation/control/deviceCommands.js +191 -0
  53. package/dist/workstation/control/deviceProtocol.js +68 -0
  54. package/dist/workstation/control/locks.js +51 -4
  55. package/dist/workstation/control/rootFormat.js +16 -0
  56. package/dist/workstation/control/runtimeClient.js +60 -5
  57. package/dist/workstation/control/runtimeHost.js +96 -9
  58. package/dist/workstation/control/serverIdentity.js +2 -2
  59. package/dist/workstation/control/updateTransition.js +91 -0
  60. package/dist/workstation/daemon.js +262 -360
  61. package/dist/workstation/daemonctl.js +150 -12
  62. package/dist/workstation/desktopControl/automationDevice.js +219 -0
  63. package/dist/workstation/desktopControl/automationJson.js +74 -0
  64. package/dist/workstation/desktopControl/automationLocalState.js +23 -0
  65. package/dist/workstation/desktopControl/automationPreparation.js +1 -0
  66. package/dist/workstation/desktopControl/automationProtocol.js +253 -0
  67. package/dist/workstation/desktopControl/automationSchema.generated.js +1 -0
  68. package/dist/workstation/desktopControl/automationSession.js +81 -0
  69. package/dist/workstation/desktopControl/automationTransport.js +138 -0
  70. package/dist/workstation/desktopControl/capabilities.js +2 -1
  71. package/dist/workstation/desktopControl/deviceAdmission.js +229 -0
  72. package/dist/workstation/desktopControl/deviceOwnership.js +130 -0
  73. package/dist/workstation/desktopControl/deviceSockets.js +2 -2
  74. package/dist/workstation/desktopControl/helperProcess.js +250 -1
  75. package/dist/workstation/desktopControl/hostGrant.js +65 -0
  76. package/dist/workstation/desktopControl/lobby.js +4 -2
  77. package/dist/workstation/desktopControl/macProvider.js +41 -40
  78. package/dist/workstation/desktopControl/nativeDeviceAdmission.js +207 -0
  79. package/dist/workstation/desktopControl/nativeProvider.js +31 -5
  80. package/dist/workstation/desktopControl/ownershipProtocol.js +31 -0
  81. package/dist/workstation/desktopControl/protocol.js +15 -6
  82. package/dist/workstation/desktopControl/supervisor.js +110 -17
  83. package/dist/workstation/desktopControl/wallpaper.js +26 -6
  84. package/dist/workstation/exec/policy.js +5 -0
  85. package/dist/workstation/files/client.js +10 -1
  86. package/dist/workstation/files/host.js +9 -2
  87. package/dist/workstation/journal.js +5 -2
  88. package/dist/workstation/keepers/acp/client.js +38 -2
  89. package/dist/workstation/keepers/kit/buildVersion.js +35 -11
  90. package/dist/workstation/keepers/kit/client.js +2 -2
  91. package/dist/workstation/keepers/kit/launcher.js +80 -4
  92. package/dist/workstation/keepers/legacy.js +16 -2
  93. package/dist/workstation/keepers/policy.js +5 -34
  94. package/dist/workstation/keepers/terminal/client.js +115 -23
  95. package/dist/workstation/keepers/terminal/exec.js +6 -3
  96. package/dist/workstation/keepers/terminal/frames.js +73 -0
  97. package/dist/workstation/keepers/terminal/generations.js +5 -8
  98. package/dist/workstation/keepers/terminal/gridProtocol.js +16 -0
  99. package/dist/workstation/keepers/terminal/input.js +146 -0
  100. package/dist/workstation/keepers/terminal/keeper.js +22 -8
  101. package/dist/workstation/keepers/terminal/main.js +1 -1
  102. package/dist/workstation/keepers/terminal/metadata.js +138 -0
  103. package/dist/workstation/keepers/terminal/protocol.js +4 -28
  104. package/dist/workstation/keepers/terminal/pty.js +1 -330
  105. package/dist/workstation/keepers/terminal/screen.js +246 -0
  106. package/dist/workstation/keepers/terminal/sessions.js +526 -0
  107. package/dist/workstation/oauth/deviceAuthorization.js +31 -17
  108. package/dist/workstation/protocol/management.js +23 -4
  109. package/dist/workstation/rd/artifact.js +4 -3
  110. package/dist/workstation/rd/control.js +3 -5
  111. package/dist/workstation/rd/host.js +213 -0
  112. package/dist/workstation/rd/migrationReadiness.js +46 -0
  113. package/dist/workstation/rd/provider.js +44 -10
  114. package/dist/workstation/rd/state.js +14 -2
  115. package/dist/workstation/rd/update.js +5 -8
  116. package/dist/workstation/rd/upkeep.js +44 -2
  117. package/dist/workstation/registrations/enrollment.js +250 -0
  118. package/dist/workstation/registrations/enrollmentPending.js +361 -0
  119. package/dist/workstation/registrations/migration.js +356 -0
  120. package/dist/workstation/registrations/model.js +34 -0
  121. package/dist/workstation/registrations/removal.js +222 -0
  122. package/dist/workstation/registrations/store.js +737 -0
  123. package/dist/workstation/runtime/desktopBinding.js +20 -0
  124. package/dist/workstation/runtime/deviceDesktop.js +193 -0
  125. package/dist/workstation/runtime/deviceRuntime.js +568 -0
  126. package/dist/workstation/runtime/deviceServices.js +117 -0
  127. package/dist/workstation/runtime/events.js +20 -0
  128. package/dist/workstation/runtime/ownership.js +100 -0
  129. package/dist/workstation/runtime/registrationRuntime.js +575 -0
  130. package/dist/workstation/runtime/remoteDesktopAdapter.js +186 -0
  131. package/dist/workstation/runtime/removalActivity.js +184 -0
  132. package/dist/workstation/service/installer.js +93 -1
  133. package/dist/workstation/service/launchd.js +1 -0
  134. package/dist/workstation/service/systemd.js +1 -0
  135. package/dist/workstation/systemMetrics.js +3 -0
  136. package/dist/workstation/tools/client.js +8 -27
  137. package/dist/workstation/tools/info.js +6 -13
  138. package/dist/workstation/tools/terminalRouter.js +17 -172
  139. package/dist/workstation/tools/validation.js +55 -1
  140. package/dist/workstation/update/archiveEvidence.js +206 -0
  141. package/dist/workstation/update/coordinator.js +34 -13
  142. package/dist/workstation/update/deviceCompatibility.js +119 -0
  143. package/dist/workstation/update/deviceCoordinator.js +369 -0
  144. package/dist/workstation/update/deviceHealth.js +104 -0
  145. package/dist/workstation/update/deviceHealthProtocol.js +26 -0
  146. package/dist/workstation/update/deviceRecoveryLifecycle.js +46 -0
  147. package/dist/workstation/update/deviceRollback.js +78 -0
  148. package/dist/workstation/update/globalInstall.js +80 -10
  149. package/dist/workstation/update/legacyBridge.js +414 -0
  150. package/dist/workstation/update/legacyOwner.js +39 -0
  151. package/dist/workstation/update/npmInstaller.js +2 -2
  152. package/dist/workstation/update/privateDurability.js +61 -0
  153. package/dist/workstation/update/recoveryPlan.js +124 -0
  154. package/dist/workstation/update/runUpdate.js +7 -1
  155. package/dist/workstation/update/taskRegistry.js +161 -3
  156. package/dist/workstation/update/watchdog.js +27427 -14410
  157. package/dist/workstation/update/watchdogProcess.js +72 -0
  158. package/package.json +7 -3
  159. package/dist/native/darwin-arm64/axom-quic +0 -0
@@ -5,6 +5,7 @@ import { SESSION_WINDOW_BYTES, SESSION_WINDOW_EVENTS } from './acp/outbox.js';
5
5
  import { SocketGate } from './agentFlow.js';
6
6
  import { AGENT_PROBE_TIMEOUT_MS, AgentRefusedSessionError, LOCAL_SESSION_UNMOVABLE } from './acp/acpAgents.js';
7
7
  import { projectFrame } from './acp/processor/projection.js';
8
+ import { TaskRegistry } from './update/taskRegistry.js';
8
9
  import { reportsActivity, supportsAsyncTaskStop } from './acp/activityReporting.js';
9
10
  import { activityIsBusy } from './acp/activityBusy.js';
10
11
  import { ADAPTER_LOST_REOPEN_ATTEMPTS, plainSwitchBlocker, REOPEN_REQUEST_RETRY_MS, switchBlockers, SWITCH_QUIET_MS } from './acp/switching.js';
@@ -230,6 +231,8 @@ export class AgentSessionManager {
230
231
  agents;
231
232
  journalDir;
232
233
  tasks;
234
+ admission;
235
+ ownership;
233
236
  sessions = new Map();
234
237
  /** A failed recorded-build respawn keeps this binding even after no LiveSession remains. */
235
238
  retainedRespawnBuilds = new Map();
@@ -338,10 +341,12 @@ export class AgentSessionManager {
338
341
  /** `<configDir>/agents`: the legacy journals (`sessions/<ref>.jsonl`) an older CLI left are migrated from here, once. */
339
342
  journalDir,
340
343
  /** Shared update admission and force-cancellation authority, when this daemon supports updates. */
341
- tasks) {
344
+ tasks, admission = () => true, ownership) {
342
345
  this.agents = agents;
343
346
  this.journalDir = journalDir;
344
347
  this.tasks = tasks;
348
+ this.admission = admission;
349
+ this.ownership = ownership;
345
350
  this.send = send;
346
351
  }
347
352
  /** Where an older CLI's journals live: `<journalDir>/sessions/<sessionRef>.jsonl` (read-only legacy, `journal.ts`). */
@@ -369,7 +374,7 @@ export class AgentSessionManager {
369
374
  }
370
375
  for (const sessionRef of refs) {
371
376
  try {
372
- const entries = await readLegacyJournal(this.sessionsDir, sessionRef);
377
+ const entries = await readLegacyJournal(this.sessionsDir, sessionRef, this.ownership);
373
378
  const imported = await importOutbox(sessionRef, entries.map((entry) => ({
374
379
  seq: entry.seq,
375
380
  ...(entry.src ? { src: entry.src } : {}),
@@ -560,7 +565,7 @@ export class AgentSessionManager {
560
565
  this.send = send;
561
566
  this.serverAgentWire = serverAgentWire;
562
567
  // A new connection: its own window and socket. What was sent on the previous one waits for the server's reopen to say how far
563
- // it got; what was never sent goes out now.
568
+ // it got. An already-exited adapter's recovered turn is replayed in full to finish it before reopen.
564
569
  this.epoch += 1;
565
570
  for (const live of this.sessions.values())
566
571
  live.session.outbox.connectionChanged();
@@ -568,6 +573,9 @@ export class AgentSessionManager {
568
573
  // Before any inbound frame on this connection can be answered: a sweep notice that arrived here
569
574
  // after the server had already re-opened its ref would invalidate a healthy generation.
570
575
  this.flushSwept();
576
+ for (const live of this.sessions.values())
577
+ if (live.replayDeadTail)
578
+ live.session.outbox.resendAll();
571
579
  this.pump();
572
580
  // Account observations are outside the session journal. Replay the cached state on reconnect
573
581
  // so a server restart does not depend on the provider sending another quota notification.
@@ -738,12 +746,17 @@ export class AgentSessionManager {
738
746
  highestScopedJournalSeq = Math.max(highestScopedJournalSeq, item.seq);
739
747
  }
740
748
  const live = {
741
- sessionRef: candidate.sessionRef, adopted: true, agentId: candidate.agentId, agentKey: candidate.agentKey, session,
749
+ sessionRef: candidate.sessionRef, adopted: true, replayDeadTail: candidate.agentAlive === false, agentId: candidate.agentId, agentKey: candidate.agentKey, session,
742
750
  nativeSessionId: session.nativeSessionId ?? null,
743
751
  scopedReplayBlocked: false, highestScopedJournalSeq, unwatch: () => { }, replays: Promise.resolve(), closed: false,
744
752
  lastActivityAt: Date.now(),
745
753
  };
746
- this.register(live);
754
+ this.register(live, this.adoptionOrigin);
755
+ // An exited adapter is adopted only to finish its recovered turn. Release its entire retained
756
+ // prefix in order so that tail can settle the server's turn before a reopen respawns the adapter.
757
+ // Live adapters retain the ordinary reopen/watermark gate.
758
+ if (live.replayDeadTail)
759
+ live.session.outbox.resendAll();
747
760
  console.info(`[acp:${live.agentId} agent:${live.agentKey}] adopted session ${live.sessionRef} from keeper-acp`);
748
761
  }
749
762
  /** Waits (bounded) for the start-time adoption; what is still rebuilding afterwards is reported as such. */
@@ -778,6 +791,23 @@ export class AgentSessionManager {
778
791
  clearTimeout(timer);
779
792
  }
780
793
  async handleFrame(frame) {
794
+ const live = 'sessionRef' in frame && typeof frame.sessionRef === 'string' ? this.liveSession(frame.sessionRef) : undefined;
795
+ const openContinuation = frame.t === 'agent_open' && ((live && !this.needsRespawn(live)) || this.opening.has(frame.sessionRef) || this.adopting.has(frame.sessionRef));
796
+ const newWork = !openContinuation && ['agent_open', 'agent_prompt', 'agent_list_native', 'agent_delete_native', 'agent_account_read', 'agent_verify', 'agent_authenticate', 'agent_install', 'agent_switch_idle'].includes(frame.t);
797
+ const registry = this.tasks ?? this.acceptedTasks;
798
+ const ticket = newWork && this.admission() ? registry.beginAcceptedAgentWork(frame.t, frame, frame.t === 'agent_prompt' ? this.adoptionOrigin : undefined) : null;
799
+ const admitted = (!newWork && (frame.t !== 'agent_open' || this.admission())) || ticket !== null;
800
+ try {
801
+ await this.handleAcceptedFrame(frame, ticket, admitted, registry);
802
+ }
803
+ finally {
804
+ if (ticket)
805
+ registry.finishAcceptedAgentWork(ticket);
806
+ }
807
+ }
808
+ acceptedTasks = new TaskRegistry();
809
+ adoptionOrigin = {};
810
+ async handleAcceptedFrame(frame, ticket, admitted, registry) {
781
811
  // A frame for a ref keeper-acp still runs from a previous daemon waits until that session is rebuilt (Task 21): answering
782
812
  // it "unknown session" would end a turn that is going on.
783
813
  const frameRef = 'sessionRef' in frame && typeof frame.sessionRef === 'string' ? frame.sessionRef : undefined;
@@ -792,7 +822,7 @@ export class AgentSessionManager {
792
822
  await parked;
793
823
  switch (frame.t) {
794
824
  case 'agent_open': {
795
- const admission = this.reserveOpen(frame.id, frame.sessionRef);
825
+ const admission = this.reserveOpen(frame, ticket, admitted, registry);
796
826
  if (admission === 'rejected')
797
827
  return;
798
828
  const ref = frame.sessionRef;
@@ -802,7 +832,7 @@ export class AgentSessionManager {
802
832
  }
803
833
  finally {
804
834
  if (admission === 'reserved')
805
- this.tasks?.agentOpenFinished(ref);
835
+ registry.agentOpenFinished(ref);
806
836
  const left = (this.openHandling.get(ref) ?? 1) - 1;
807
837
  if (left > 0)
808
838
  this.openHandling.set(ref, left);
@@ -818,7 +848,7 @@ export class AgentSessionManager {
818
848
  const live = this.routableSession(frame.sessionRef, 'prompt');
819
849
  if (!live)
820
850
  return;
821
- if (this.tasks && !this.tasks.tryAdmitTurn(frame.sessionRef)) {
851
+ if (!admitted) {
822
852
  // The refusal is the daemon's decision, so it is a note (`agent_prompt{admitted:false}`) and the processor ends the
823
853
  // turn `failed` with the same words (`ADMISSION_REFUSED`), numbered in the session's own sequence (spec §5.4). No
824
854
  // `authMethods`: the processor's short frame carries no `agentRefused`, so the projection adds none — the prompt
@@ -826,6 +856,14 @@ export class AgentSessionManager {
826
856
  live.session.refusePrompt(frame.platformTurnId);
827
857
  return;
828
858
  }
859
+ if (ticket)
860
+ try {
861
+ registry.consumeAcceptedAgentWork(ticket, frame.t, frame);
862
+ }
863
+ catch {
864
+ live.session.refusePrompt(frame.platformTurnId);
865
+ return;
866
+ }
829
867
  live.session.prompt(promptBlocks(frame), frame.platformTurnId);
830
868
  return;
831
869
  }
@@ -963,6 +1001,10 @@ export class AgentSessionManager {
963
1001
  * keep holding that row or to deliver it the ordinary way. A silent drop parks it forever.
964
1002
  */
965
1003
  case 'agent_steer': {
1004
+ if (!this.admission()) {
1005
+ this.emit({ t: 'agent_steered', id: frame.id, ok: false, error: 'Registration admission is paused' });
1006
+ return;
1007
+ }
966
1008
  const live = this.routableSession(frame.sessionRef, 'steer');
967
1009
  if (!live) {
968
1010
  this.emit({ t: 'agent_steered', id: frame.id, ok: false, error: `no live session '${frame.sessionRef}'` });
@@ -1004,6 +1046,10 @@ export class AgentSessionManager {
1004
1046
  * a request that never resolves.
1005
1047
  */
1006
1048
  case 'agent_set_config_option': {
1049
+ if (!this.admission()) {
1050
+ this.emit({ t: 'agent_config_option_set', id: frame.id, ok: false, error: 'Registration admission is paused' });
1051
+ return;
1052
+ }
1007
1053
  const live = this.routableSession(frame.sessionRef, 'set_config_option');
1008
1054
  if (!live) {
1009
1055
  this.emit({ t: 'agent_config_option_set', id: frame.id, ok: false, error: `no live session '${frame.sessionRef}'` });
@@ -1039,14 +1085,14 @@ export class AgentSessionManager {
1039
1085
  return;
1040
1086
  }
1041
1087
  case 'agent_list_native':
1042
- if (this.tasks && !this.tasks.accepting) {
1088
+ if (!admitted) {
1043
1089
  this.emit({ t: 'agent_native_sessions', id: frame.id, ok: false, error: 'Workstation is not accepting new work' });
1044
1090
  return;
1045
1091
  }
1046
1092
  await this.onListNative(frame);
1047
1093
  return;
1048
1094
  case 'agent_delete_native':
1049
- if (this.tasks && !this.tasks.accepting) {
1095
+ if (!admitted) {
1050
1096
  this.emit({ t: 'agent_native_deleted', id: frame.id, ok: false, error: 'Workstation is not accepting new work' });
1051
1097
  return;
1052
1098
  }
@@ -1055,7 +1101,7 @@ export class AgentSessionManager {
1055
1101
  // Both of these spawn an agent subprocess, exactly as `agent_list_native` does, so they answer
1056
1102
  // a draining workstation the same way rather than starting work it is trying to finish.
1057
1103
  case 'agent_account_read': {
1058
- if (this.tasks && !this.tasks.accepting) {
1104
+ if (!admitted) {
1059
1105
  this.emit({ t: 'agent_account_read_result', id: frame.id, ok: false, error: 'Workstation is not accepting new work' });
1060
1106
  return;
1061
1107
  }
@@ -1077,14 +1123,14 @@ export class AgentSessionManager {
1077
1123
  return;
1078
1124
  }
1079
1125
  case 'agent_verify':
1080
- if (this.tasks && !this.tasks.accepting) {
1126
+ if (!admitted) {
1081
1127
  this.emit({ t: 'agent_verified', id: frame.id, ok: false, error: 'Workstation is not accepting new work' });
1082
1128
  return;
1083
1129
  }
1084
1130
  await this.onVerify(frame);
1085
1131
  return;
1086
1132
  case 'agent_authenticate':
1087
- if (this.tasks && !this.tasks.accepting) {
1133
+ if (!admitted) {
1088
1134
  this.emit({ t: 'agent_authenticated', id: frame.id, ok: false, error: 'Workstation is not accepting new work' });
1089
1135
  return;
1090
1136
  }
@@ -1093,7 +1139,7 @@ export class AgentSessionManager {
1093
1139
  // Downloads a package; starts nothing. Still refused while draining, because a workstation on
1094
1140
  // its way down should not begin a multi-megabyte fetch it will be killed in the middle of.
1095
1141
  case 'agent_install':
1096
- if (this.tasks && !this.tasks.accepting) {
1142
+ if (!admitted) {
1097
1143
  this.emit({ t: 'agent_installed', id: frame.id, ok: false, error: 'Workstation is not accepting new work' });
1098
1144
  return;
1099
1145
  }
@@ -1101,7 +1147,7 @@ export class AgentSessionManager {
1101
1147
  return;
1102
1148
  // "Move idle sessions" (Task 25): a move spawns adapters, so a draining workstation refuses it like an install.
1103
1149
  case 'agent_switch_idle':
1104
- if (this.tasks && !this.tasks.accepting) {
1150
+ if (!admitted) {
1105
1151
  this.emit({ t: 'agent_switch_idle_result', id: frame.id, ok: false, error: 'Workstation is not accepting new work' });
1106
1152
  return;
1107
1153
  }
@@ -1149,24 +1195,31 @@ export class AgentSessionManager {
1149
1195
  * stop leaves attached. A ref absent from both maps is genuinely new work and still needs
1150
1196
  * the ordinary TaskRegistry admission reservation.
1151
1197
  */
1152
- reserveOpen(openId, sessionRef) {
1153
- if (!this.tasks)
1154
- return 'continuation';
1198
+ reserveOpen(frame, ticket, admitted, registry) {
1199
+ const { id: openId, sessionRef } = frame;
1200
+ if (!admitted) {
1201
+ this.emit({ t: 'agent_opened', id: openId, ok: false, error: 'Workstation is not accepting new work' });
1202
+ return 'rejected';
1203
+ }
1155
1204
  const live = this.liveSession(sessionRef);
1156
1205
  // A keeper-hosted session whose adapter is gone is respawned by this open: new work, not a continuation — it takes a reservation,
1157
1206
  // and a draining workstation refuses it like any open (spec §8.2; fix round 1, minor 9).
1158
1207
  const respawn = live !== undefined && this.needsRespawn(live);
1159
- if (this.tasks.isForceStopping()) {
1208
+ if (registry.isForceStopping()) {
1160
1209
  // A keeper-hosted session a force update left attached (TaskRegistry.stopAll) is still reopened as a continuation — a reconnect
1161
1210
  // during the download must not cut it off; anything else is refused.
1162
- if (live && !respawn && this.tasks.isDurableSession(sessionRef))
1211
+ if (live && !respawn && registry.isDurableSession(sessionRef))
1163
1212
  return 'continuation';
1164
1213
  this.emit({ t: 'agent_opened', id: openId, ok: false, error: 'Workstation is not accepting new work' });
1165
1214
  return 'rejected';
1166
1215
  }
1167
1216
  if ((live && !respawn) || this.opening.has(sessionRef) || this.adopting.has(sessionRef))
1168
1217
  return 'continuation';
1169
- if (this.tasks.tryReserveAgentOpen(sessionRef))
1218
+ if (ticket) {
1219
+ registry.reserveAcceptedAgentOpen(ticket, frame);
1220
+ return 'reserved';
1221
+ }
1222
+ if (registry.tryReserveAgentOpen(sessionRef))
1170
1223
  return 'reserved';
1171
1224
  this.emit({ t: 'agent_opened', id: openId, ok: false, error: 'Workstation is not accepting new work' });
1172
1225
  return 'rejected';
@@ -2241,7 +2294,7 @@ export class AgentSessionManager {
2241
2294
  return { live };
2242
2295
  }
2243
2296
  /** A session joins: held, registered with the TaskRegistry, its frames and its outbox wired to the sender. */
2244
- register(live) {
2297
+ register(live, adoption) {
2245
2298
  const session = live.session;
2246
2299
  this.detached.delete(live.sessionRef);
2247
2300
  this.sessions.set(live.sessionRef, live);
@@ -2251,7 +2304,7 @@ export class AgentSessionManager {
2251
2304
  () => this.retire(live, durable ? 'detach' : 'destroy'),
2252
2305
  // 两个层级量取或:本进程发出的提示词是否在途(agent 自己也许在续跑一个我们没发起的回合,
2253
2306
  // 那一种由第二项回答),以及 agent 自报的状态。两者都不累积,所以没有可泄漏的状态。
2254
- () => live.session.liveness.turnRunning || activityIsBusy(live.session.activityRaw), { durable });
2307
+ () => live.session.liveness.turnRunning || activityIsBusy(live.session.activityRaw), { durable, adoption });
2255
2308
  // Everything the session produces arrives as processor frames (Task 19): numbered by the processor, projected for the
2256
2309
  // connected server here, recorded in the outbox (the numbered ones) and sent from it (Task 20).
2257
2310
  live.unwatch = session.outbox.onChange(() => this.pump());
@@ -3,6 +3,7 @@ import { connectEncryptedChannel } from '../connection/encryptedChannel.js';
3
3
  import { AGENT_WIRE_MIN_VERSION, AGENT_WIRE_RESUME_OFFER_VERSION, AGENT_WIRE_VERSION, decodeAgentFrame, encodeAgentFrame, outboundAgentFrameRejection, } from '../protocol/agent.js';
4
4
  import { bridgesForWire, helloRoster } from '../protocol/common.js';
5
5
  import { workstationChannelUrl } from './url.js';
6
+ import { DeviceEventRouter } from '../runtime/events.js';
6
7
  /** Binds daemon-scoped sessions only to the encrypted agent channel's sender. */
7
8
  export class AgentChannel {
8
9
  options;
@@ -24,7 +25,14 @@ export class AgentChannel {
24
25
  });
25
26
  // Hoisted rather than inlined twice: `closeAll` compares it by identity to decide whether the
26
27
  // binding it is being asked to drop is still this connection's. See AgentSessionManager.closeAll.
27
- const send = (frame) => channel.send(frame);
28
+ const registrationId = this.options.registrationId;
29
+ const events = registrationId ? new DeviceEventRouter((_recipient, event) => channel.send(event.payload), new Set([registrationId])) : undefined;
30
+ const send = (frame) => {
31
+ if (events && registrationId)
32
+ events.publish({ scope: 'registration', registrationId, payload: frame });
33
+ else
34
+ channel.send(frame);
35
+ };
28
36
  // The socket's unsent bytes: the manager's socket gate holds session frames back above 1 MiB so the heartbeat (written by the
29
37
  // channel itself) never queues behind a backlog (spec §5.6).
30
38
  void dispatchAgentChannelFrames(channel.messages, send, this.options.agents, () => channel.close(), () => channel.bufferedAmount);
@@ -11,7 +11,8 @@ import { TaskRegistry } from '../update/taskRegistry.js';
11
11
  import { toFileError } from '../files/errors.js';
12
12
  import { object } from '../tools/validation.js';
13
13
  import { isUnaccountedTool } from '../tools/router.js';
14
- import { requestRootFileExecutors } from '../files/adapters/computerFileFrames.js';
14
+ import { requestRootFileExecutors, fileErrorFrame } from '../files/adapters/computerFileFrames.js';
15
+ import { DeviceEventRouter } from '../runtime/events.js';
15
16
  /**
16
17
  * Consumes computer frames without serializing long-running commands behind one another.
17
18
  * All command failures and post-close sends are contained here because this is called detached
@@ -211,6 +212,20 @@ export async function dispatchComputerFrames(messages, send, execute, tasks = ne
211
212
  };
212
213
  try {
213
214
  for await (const frame of messages) {
215
+ if (durable.admission?.() === false) {
216
+ if (frame.t === 'exec_request') {
217
+ safeSend({ t: 'exec_exit', id: frame.id, exitCode: null, stdout: '', stderr: 'Registration admission is paused', timedOut: false, truncated: false });
218
+ continue;
219
+ }
220
+ if (frame.t === 'tool_request' && frame.method !== 'tools.info') {
221
+ safeSend({ t: 'tool_result', id: frame.id, ok: false, result: {}, error: 'Registration admission is paused' });
222
+ continue;
223
+ }
224
+ if (frame.t === 'file_write_request' || frame.t === 'file_mkdir_request') {
225
+ safeSend(fileErrorFrame(frame, { code: 'workstation_offline', message: 'Registration admission is paused' }));
226
+ continue;
227
+ }
228
+ }
214
229
  if (frame.t.startsWith('desktop_'))
215
230
  desktop?.onFrame(frame);
216
231
  else if (frame.t === 'tool_request')
@@ -279,9 +294,17 @@ export class ComputerChannel {
279
294
  return { closed: channel.closed, close: () => channel.close() };
280
295
  }
281
296
  async dispatch(channel) {
282
- const desktop = new DesktopBridge(resolveBrowserBinding(this.options.browserBinding), frame => channel.send(frame), () => channel.bufferedAmount);
283
- await dispatchComputerFrames(channel.messages, (frame) => channel.send(frame), this.options.runExec ?? ((request, onOutput, signal) => runExec(request, onOutput, signal)), this.tasks, this.options, this.tools, desktop, {
284
- host: this.execs, bufferedAmount: () => channel.bufferedAmount, log: this.options.log,
297
+ const registrationId = this.options.registrationId;
298
+ const events = registrationId ? new DeviceEventRouter((_recipient, event) => channel.send(event.payload), new Set([registrationId])) : undefined;
299
+ const send = (frame) => {
300
+ if (events && registrationId)
301
+ events.publish({ scope: 'registration', registrationId, payload: frame });
302
+ else
303
+ channel.send(frame);
304
+ };
305
+ const desktop = new DesktopBridge(this.options.desktopEnabled === false ? undefined : resolveBrowserBinding(this.options.browserBinding), frame => channel.send(frame), () => channel.bufferedAmount);
306
+ await dispatchComputerFrames(channel.messages, send, this.options.runExec ?? ((request, onOutput, signal) => runExec(request, onOutput, signal)), this.tasks, this.options, this.tools, desktop, {
307
+ host: this.execs, bufferedAmount: () => channel.bufferedAmount, log: this.options.log, admission: this.options.admission,
285
308
  });
286
309
  }
287
310
  }
@@ -97,7 +97,16 @@ export function metricsForManagementWire(metrics, serverManagementWire) {
97
97
  *
98
98
  * Exported for tests; production's only caller is `routeMessages`.
99
99
  */
100
- export function dispatchUpdateCommand(channel, command, onUpdate, serverManagementWire) {
100
+ export function dispatchUpdateCommand(channel, command, onUpdate, serverManagementWire, requiredRemoteUpdatePolicy) {
101
+ if (requiredRemoteUpdatePolicy && command.remoteForcePolicy !== requiredRemoteUpdatePolicy) {
102
+ if (channel.state === 'OPEN') {
103
+ try {
104
+ channel.send(toClientUpdateStatusFrame(command, { phase: 'FAILED', remoteForcePolicy: 'drain-only', error: 'upgrade_required: the server must support authenticated device update readiness' }, serverManagementWire));
105
+ }
106
+ catch { }
107
+ }
108
+ return Promise.resolve();
109
+ }
101
110
  return onUpdate(command)
102
111
  .then((status) => {
103
112
  if (channel.state !== 'OPEN')
@@ -189,12 +198,27 @@ export class ManagementChannel {
189
198
  constructor(options) {
190
199
  this.options = options;
191
200
  }
201
+ async receiveDeviceFact(event) {
202
+ const generation = this.generation;
203
+ const channel = this.channel;
204
+ if (event.scope !== 'device' || !generation || channel?.state !== 'OPEN')
205
+ return;
206
+ if (event.kind === 'tool-state' && this.options.onExternalAgentsScan) {
207
+ const bridges = await this.options.onExternalAgentsScan();
208
+ if (this.generation === generation && this.channel === channel)
209
+ this.pushRoster(bridges);
210
+ }
211
+ else if (event.kind === 'desktop-capability')
212
+ this.refreshDesktopCapabilities(generation);
213
+ }
192
214
  refreshDesktopCapabilities(generation) {
193
215
  if (generation === undefined || generation === this.generation)
194
216
  this.desktopReporter?.refresh();
195
217
  }
196
218
  /** T10 reports only on its authenticated current management socket. */
197
219
  sendDesktopState(generation, frame) {
220
+ if (frame.t === 'desktop_ownership_result' && (this.serverManagementWire ?? 0) < 10)
221
+ return false;
198
222
  if (generation !== this.generation || (this.serverManagementWire ?? 0) < 9 || this.channel?.state !== 'OPEN')
199
223
  return false;
200
224
  try {
@@ -293,7 +317,7 @@ export class ManagementChannel {
293
317
  // is never driven by the local browser/device-authorization flow.
294
318
  await this.options.onReady?.({ generation: ready.generation, selectedWire: ready.protocols.MANAGEMENT, release: ready.release });
295
319
  const pending = new Map();
296
- void this.routeMessages(channel, pending);
320
+ void this.routeMessages(channel, pending, ready.generation);
297
321
  return {
298
322
  generation: ready.generation,
299
323
  closed: channel.closed,
@@ -344,17 +368,24 @@ export class ManagementChannel {
344
368
  return frame;
345
369
  throw new Error('Management channel closed before ready');
346
370
  }
347
- async routeMessages(channel, pending) {
371
+ async routeMessages(channel, pending, generation) {
348
372
  try {
349
373
  for await (const frame of channel.messages) {
350
374
  if (channel !== this.channel)
351
375
  break;
376
+ if (frame.t === 'desktop_automation_prepare') {
377
+ if (frame.deviceGeneration === this.generation && this.generation && this.options.onAutomationPrepare)
378
+ void this.options.onAutomationPrepare(frame, this.generation).catch(() => { });
379
+ continue;
380
+ }
352
381
  if (frame.t === 'workstation_capabilities_request') {
353
382
  if ((this.serverManagementWire ?? 0) >= 9 && frame.generation === this.generation)
354
383
  this.desktopReporter?.refresh();
355
384
  continue;
356
385
  }
357
- if ((frame.t === 'desktop_background' || frame.t === 'desktop_lobby' || frame.t === 'desktop_prepare' || frame.t === 'desktop_cancel') && (this.serverManagementWire ?? 0) >= 9) {
386
+ if ((frame.t === 'desktop_background' || frame.t === 'desktop_lobby' || frame.t === 'desktop_prepare' || frame.t === 'desktop_cancel' || frame.t === 'desktop_ownership') && (this.serverManagementWire ?? 0) >= 9) {
387
+ if (frame.t === 'desktop_ownership' && (this.serverManagementWire ?? 0) < 10)
388
+ continue;
358
389
  if (frame.t !== 'desktop_cancel' && frame.deviceGeneration !== this.generation)
359
390
  continue;
360
391
  if (this.options.onDesktopCommand && this.generation) {
@@ -374,7 +405,7 @@ export class ManagementChannel {
374
405
  if (frame.t === 'client_update' && this.options.onUpdate) {
375
406
  // Deliberately not awaited — see dispatchUpdateCommand for why inline handling both
376
407
  // stalled this loop for the whole update and threw the final status away.
377
- void dispatchUpdateCommand(channel, frame, this.options.onUpdate, this.serverManagementWire);
408
+ void dispatchUpdateCommand(channel, frame, command => this.options.onUpdate(command, generation), this.serverManagementWire, this.options.requiredRemoteUpdatePolicy);
378
409
  continue;
379
410
  }
380
411
  if (frame.t === 'keeper_control' && this.options.onKeeperControl) {
@@ -6,6 +6,8 @@ import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import yaml from 'js-yaml';
8
8
  import { z } from 'zod';
9
+ import { withWorkstationLock } from './control/locks.js';
10
+ import { assertLegacyRoot } from './control/rootFormat.js';
9
11
  const localPath = z.string().refine(p => path.isAbsolute(p) && !p.includes('\0'));
10
12
  const nativeConfig = z.strictObject({ kind: z.literal('linux-native'), helperPath: localPath, runtimeDir: localPath });
11
13
  export function updateServiceContext(configDir = clientDir(), overrides = {}) {
@@ -14,10 +16,14 @@ export function updateServiceContext(configDir = clientDir(), overrides = {}) {
14
16
  const managed = loadManagedCliInstallSync(packageRoot);
15
17
  const nodePath = managed?.nodePath ?? overrides.nodePath ?? process.execPath;
16
18
  const npmPrefix = managed?.prefix ?? overrides.npmPrefix ?? process.env.AXOM_WORKSTATION_NPM_PREFIX ?? path.resolve(packageRoot, '../../../..');
19
+ // A previous sudo npm command can leave the interactive user's cache root-owned. Updates and
20
+ // their detached rollback watchdog share a cache owned by this workstation service instead.
21
+ const env = { ...npmExecutionContext({ prefix: npmPrefix, nodePath }, process.env), NPM_CONFIG_CACHE: path.join(configDir, 'update/npm-cache') };
22
+ delete env.npm_config_cache;
17
23
  return {
18
24
  configDir,
19
25
  ...(managed ? { managed } : {}),
20
- env: npmExecutionContext({ prefix: npmPrefix, nodePath }, process.env),
26
+ env,
21
27
  platform: overrides.platform ?? (process.platform === 'darwin' ? 'darwin' : 'linux'),
22
28
  nodePath,
23
29
  npmPath: managed?.npmPath ?? overrides.npmPath ?? process.env.AXOM_WORKSTATION_NPM_PATH ?? path.join(path.dirname(nodePath), 'npm'),
@@ -33,8 +39,11 @@ export function clientDir() {
33
39
  return process.env.AXOM_WORKSTATION_CONFIG_DIR ?? path.join(os.homedir(), '.axom', 'workstation');
34
40
  }
35
41
  export async function saveClientConfig(dir, cfg) {
36
- await fs.mkdir(dir, { recursive: true });
37
- await fs.writeFile(path.join(dir, 'config.yml'), yaml.dump(cfg), { mode: 0o600 });
42
+ await withWorkstationLock(dir, 'operation', async () => {
43
+ await assertLegacyRoot(dir);
44
+ await fs.mkdir(dir, { recursive: true });
45
+ await fs.writeFile(path.join(dir, 'config.yml'), yaml.dump(cfg), { mode: 0o600 });
46
+ });
38
47
  }
39
48
  export async function loadClientConfig(dir) {
40
49
  const raw = await fs.readFile(path.join(dir, 'config.yml'), 'utf8');
@@ -50,9 +59,12 @@ export async function loadClientConfig(dir) {
50
59
  return { ...(browserBinding ? { browserBinding } : {}), ...(desktopProvider ? { desktopProvider } : {}), server: parsed.server, workstationId: parsed.workstationId, ...(typeof parsed.ownerUserId === 'string' && parsed.ownerUserId ? { ownerUserId: parsed.ownerUserId } : {}), ...(Array.isArray(parsed.workspaces) ? { workspaces: parsed.workspaces } : {}) };
51
60
  }
52
61
  export async function writeKeys(dir, clientKeyB64, serverPubB64) {
53
- await fs.mkdir(dir, { recursive: true });
54
- await fs.writeFile(path.join(dir, 'client.key'), clientKeyB64, { mode: 0o600 });
55
- await fs.writeFile(path.join(dir, 'server.pub'), serverPubB64);
62
+ await withWorkstationLock(dir, 'operation', async () => {
63
+ await assertLegacyRoot(dir);
64
+ await fs.mkdir(dir, { recursive: true });
65
+ await fs.writeFile(path.join(dir, 'client.key'), clientKeyB64, { mode: 0o600 });
66
+ await fs.writeFile(path.join(dir, 'server.pub'), serverPubB64);
67
+ });
56
68
  }
57
69
  export async function readKeys(dir) {
58
70
  const clientKey = Buffer.from((await fs.readFile(path.join(dir, 'client.key'), 'utf8')).trim(), 'base64');
@@ -7,6 +7,7 @@ import { loadClientConfig } from '../config.js';
7
7
  import { privateDirectory } from '../keepers/kit/paths.js';
8
8
  import { bindingSchema, operationRecordSchema, LocalControlError } from './protocol.js';
9
9
  import { normalizeWorkstationServer } from './serverIdentity.js';
10
+ import { assertLegacyRoot } from './rootFormat.js';
10
11
  const IDENTITY_FILES = ['client.key', 'server.pub', 'config.yml'];
11
12
  const missing = (error) => error.code === 'ENOENT';
12
13
  async function readPrivateFile(file) {
@@ -106,6 +107,7 @@ export class BindingStore {
106
107
  }
107
108
  }
108
109
  async recover() {
110
+ await assertLegacyRoot(this.directory);
109
111
  if (!await exists(this.pending))
110
112
  return;
111
113
  const stat = await fs.lstat(this.pending);
@@ -148,6 +150,7 @@ export class BindingStore {
148
150
  await syncDirectory(this.directory);
149
151
  }
150
152
  async commit(config, keys, operation, repair = false) {
153
+ await assertLegacyRoot(this.directory);
151
154
  const binding = bindingOf(config);
152
155
  validateKeys(keys);
153
156
  const safeOperation = operationRecordSchema.parse(operation);
@@ -176,6 +179,7 @@ export class BindingStore {
176
179
  await this.recover();
177
180
  }
178
181
  async clear(expectedRevision) {
182
+ await assertLegacyRoot(this.directory);
179
183
  await this.recover();
180
184
  const current = await this.inspect();
181
185
  if (current.revision !== expectedRevision || current.identityState !== 'ready')
@@ -10,6 +10,7 @@ import { inspectWorkstation } from './inspect.js';
10
10
  import { enrollWorkstation, enrollmentFailure } from './enrollment.js';
11
11
  import { withWorkstationLock } from './locks.js';
12
12
  import { bindingGuardSchema, LocalControlError } from './protocol.js';
13
+ export { executeDeviceCommand } from './deviceCommands.js';
13
14
  const lifecycleRequest = z.object({ operationId: z.string().min(1).max(128), guard: bindingGuardSchema }).strict();
14
15
  export async function readLocalRequest(input) {
15
16
  const chunks = [];