@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
@@ -1,7 +1,7 @@
1
1
  // Daemon-side: starts, probes and stops keepers. Exempt from the keeper-side import guard.
2
2
  import { spawn } from 'node:child_process';
3
3
  import { promises as fs } from 'node:fs';
4
- import { constants, openSync, fstatSync, readFileSync, closeSync, lstatSync } from 'node:fs';
4
+ import { constants, openSync, fstatSync, readFileSync, closeSync, lstatSync, mkdirSync, writeFileSync } from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { keeperOneShot, readKeeperToken } from './client.js';
@@ -10,6 +10,7 @@ import { keeperGen } from './identity.js';
10
10
  import { encodeLaunchConfig, launchConfigOf } from './launchConfig.js';
11
11
  import { acquireStartupLock, liveManifest, manifestProcessLive, pidAlive, processCommand, readPidManifest, releaseStartupLock } from './lock.js';
12
12
  import { bundleDirFor, ensureKeeperDirectories, keeperPaths, touchSocket } from './paths.js';
13
+ import { assertOwnedDirectory, assertRegistrationOwner, legacyOwnershipAllowed, legacySourcesOf, readOwnerFile, RegistrationOwnershipError } from '../../runtime/ownership.js';
13
14
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
14
15
  /** `keeper.ping` on a fresh connection. Null unless the answer is from this exact keeper generation. */
15
16
  export async function pingKeeper(paths, timeoutMs = 250) {
@@ -47,11 +48,70 @@ export class KeeperLauncher {
47
48
  constructor(options) {
48
49
  this.options = options;
49
50
  }
51
+ get ownership() { return this.options.ownership; }
52
+ assertOwnership(kind, gen = 'ownership') { this.pathsFor(kind, gen); }
53
+ allowsLegacyOwnership(kind, gen) {
54
+ const sources = legacySourcesOf(this.ownership);
55
+ return !!sources && this.options.server === sources.keeperServer && path.resolve(this.options.configDir) === path.resolve(sources.adapterCacheDirectory) && legacyOwnershipAllowed(this.ownership, this.pathsFor(kind, gen).root, kind);
56
+ }
57
+ assertSessionOwnership(label, gen) {
58
+ assertRegistrationOwner(label, this.ownership, this.allowsLegacyOwnership('acp', gen) ? { directory: this.pathsFor('acp', gen).root, kind: 'acp' } : undefined);
59
+ }
60
+ assertResourceOwnership(kind, gen, resource, id, create = false) {
61
+ if (!this.ownership)
62
+ return;
63
+ const paths = this.pathsFor(kind, gen);
64
+ const directory = path.join(paths.dataDir, 'registration-owners', resource);
65
+ const file = path.join(directory, `${encodeURIComponent(id)}.json`);
66
+ try {
67
+ assertRegistrationOwner(readOwnerFile(file), this.ownership);
68
+ return;
69
+ }
70
+ catch (error) {
71
+ if (error.code !== 'ENOENT')
72
+ throw error;
73
+ }
74
+ if (!create && !this.allowsLegacyOwnership(kind, gen))
75
+ throw new RegistrationOwnershipError('Registration resource owner is unknown; state quarantined');
76
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
77
+ try {
78
+ writeFileSync(file, JSON.stringify({ schemaVersion: 1, registrationId: this.ownership.registrationId }), { mode: 0o600, flag: 'wx' });
79
+ }
80
+ catch (error) {
81
+ if (error.code !== 'EEXIST')
82
+ throw error;
83
+ }
84
+ assertRegistrationOwner(readOwnerFile(file), this.ownership);
85
+ }
86
+ async assertKeeperResources(kind, gen, timeoutMs = 1000) {
87
+ if (!this.ownership)
88
+ return;
89
+ const paths = this.pathsFor(kind, gen);
90
+ const token = await readKeeperToken(paths.tokenFile);
91
+ if (kind === 'acp') {
92
+ const listed = await keeperOneShot(paths.socket, 'acp.list', {}, timeoutMs, { kind, token });
93
+ if (!Array.isArray(listed.sessions))
94
+ throw new RegistrationOwnershipError('Registration session ownership needs verification');
95
+ for (const session of listed.sessions)
96
+ this.assertSessionOwnership(session.label, gen);
97
+ }
98
+ else {
99
+ const ptys = await keeperOneShot(paths.socket, 'pty.list', {}, timeoutMs, { kind, token });
100
+ const execs = await keeperOneShot(paths.socket, 'exec.list', {}, timeoutMs, { kind, token });
101
+ if (!Array.isArray(ptys.sessions) || !Array.isArray(execs.execs))
102
+ throw new RegistrationOwnershipError('Registration shell ownership needs verification');
103
+ for (const session of ptys.sessions)
104
+ this.assertResourceOwnership(kind, gen, 'pty', session.id);
105
+ for (const exec of execs.execs)
106
+ this.assertResourceOwnership(kind, gen, 'exec', exec.execId);
107
+ }
108
+ }
50
109
  get distDir() { return this.options.distDir ?? defaultDistDir(); }
51
110
  probe(paths, timeoutMs) { return this.options.probe ? this.options.probe(paths) : pingKeeper(paths, timeoutMs); }
52
111
  /** Paths of generation `gen` of `kind` (no build needed). */
53
112
  pathsFor(kind, gen) {
54
- const scoped = keeperPaths(this.options, kind, gen);
113
+ const scoped = keeperPaths(this.options, kind, gen, { fallbackRoot: this.options.fallbackRoot });
114
+ assertOwnedDirectory(scoped.root, this.ownership, kind);
55
115
  try {
56
116
  lstatSync(scoped.pidFile);
57
117
  return scoped;
@@ -68,12 +128,17 @@ export class KeeperLauncher {
68
128
  if (!stat.isFile() || stat.size > 4096 || process.getuid && stat.uid !== process.getuid())
69
129
  throw new Error('Unsafe keeper PID manifest');
70
130
  const manifest = JSON.parse(readFileSync(descriptor, 'utf8'));
71
- if (manifest.identity === scoped.identity && manifest.kind === kind && manifest.gen === gen)
131
+ if (manifest.identity === scoped.identity && manifest.kind === kind && manifest.gen === gen) {
132
+ assertOwnedDirectory(legacy.root, this.ownership, kind);
72
133
  return legacy;
134
+ }
73
135
  }
74
136
  catch (error) {
75
- if (error.code !== 'ENOENT')
137
+ if (error.code !== 'ENOENT') {
138
+ if (this.ownership && !(error instanceof RegistrationOwnershipError))
139
+ throw new RegistrationOwnershipError('Keeper registration ownership could not be verified; state quarantined', error);
76
140
  throw error;
141
+ }
77
142
  }
78
143
  finally {
79
144
  if (descriptor !== undefined)
@@ -196,7 +261,10 @@ export class KeeperLauncher {
196
261
  // `detached` is what makes it survive; do not add launchd keys or drop it.
197
262
  const child = spawn(nodePath, [script, config], { detached: true, stdio: 'ignore' });
198
263
  child.on('error', () => { });
264
+ this.options.onNativeAcquired?.(child, paths);
199
265
  child.unref();
266
+ if (child.pid)
267
+ await this.options.onNativeSpawn?.(child.pid, paths, child);
200
268
  }
201
269
  /**
202
270
  * Spec §3.1: a socket that fell back to `/tmp` (its configDir path is too long for sockaddr_un) is touched for every generation
@@ -244,6 +312,14 @@ export class KeeperLauncher {
244
312
  }
245
313
  return true;
246
314
  };
315
+ if (this.ownership) {
316
+ try {
317
+ await this.assertKeeperResources(kind, gen);
318
+ }
319
+ catch {
320
+ return { stopped: false, pid, reason: 'Registration resource ownership needs verification' };
321
+ }
322
+ }
247
323
  // keeper.stop needs the connection token (the daemon owns the run directory it lives in). Without a usable token
248
324
  // the keeper cannot be asked, so after the same identity check the force path uses, SIGTERM it (its handler is the graceful stop).
249
325
  let reason;
@@ -9,8 +9,19 @@
9
9
  import { execFile, spawn } from 'node:child_process';
10
10
  import { promises as fs } from 'node:fs';
11
11
  import path from 'node:path';
12
- import { keeperPaths, keeperRpc } from '../tools/client.js';
12
+ import os from 'node:os';
13
+ import { createHash } from 'node:crypto';
14
+ import { keeperRpc } from '../tools/client.js';
13
15
  import { pidAlive } from './kit/lock.js';
16
+ import { legacySourcesOf } from '../runtime/ownership.js';
17
+ export function legacyKeeperPaths(options) {
18
+ const identity = createHash('sha256').update(JSON.stringify([path.resolve(options.configDir), options.workstationId, options.server])).digest('hex').slice(0, 32);
19
+ const namespace = `axom-tools-${process.getuid?.() ?? 'user'}`;
20
+ let dir = path.join(process.platform === 'darwin' ? '/tmp' : os.tmpdir(), namespace, identity);
21
+ if (Buffer.byteLength(path.join(dir, 'keeper.sock')) > 100)
22
+ dir = path.join('/tmp', namespace, identity);
23
+ return { dir, socket: path.join(dir, 'keeper.sock'), lock: path.join(dir, 'startup.lock'), pidFile: path.join(dir, 'keeper.pid.json'), lockPort: 20000 + Number.parseInt(identity.slice(0, 8), 16) % 40000, unit: `axom-tools-keeper-${identity}` };
24
+ }
14
25
  /** A legacy keeper with no open PTY is stopped once nothing was routed to it for this long (the user may still read an exited one). */
15
26
  export const LEGACY_IDLE_MS = 10 * 60_000;
16
27
  /**
@@ -57,9 +68,12 @@ export class LegacyToolsKeeper {
57
68
  rpc;
58
69
  constructor(options) {
59
70
  this.options = options;
60
- this.paths = options.paths ?? keeperPaths({ ...options.tools }, undefined, undefined, 'terminal');
71
+ this.paths = options.paths ?? legacyKeeperPaths(options.tools);
61
72
  this.now = options.now ?? Date.now;
62
73
  this.rpc = options.rpc ?? keeperRpc;
74
+ const sources = legacySourcesOf(options.ownership);
75
+ if (options.ownership && (!sources || path.resolve(options.tools.configDir) !== path.resolve(sources.adapterCacheDirectory) || options.tools.server !== sources.keeperServer))
76
+ this.current = 'absent';
63
77
  }
64
78
  get state() { return this.current; }
65
79
  /** Present or busy: it may hold sessions, so the router asks it. */
@@ -19,8 +19,6 @@
19
19
  // older one gets a persistent intent to move there when idle (`moveOffOlderKeeperGenerations`, Task 24); an older
20
20
  // generation that hosts no adapter any more is stopped (`retireIdleGenerations`). Never for `available` unless asked.
21
21
  // replace-now stops the older generations at once (their in-flight turns end; the sessions reopen through session/load).
22
- // legacy the legacy tools keeper (spec §9) retires by itself (keepers/legacy.ts); it is reported `required`/`when-idle`, and
23
- // replace-now stops it at once.
24
22
  import { promises as fs } from 'node:fs';
25
23
  import path from 'node:path';
26
24
  import { keeperOneShot, readKeeperToken } from './kit/client.js';
@@ -268,24 +266,8 @@ export class KeeperPolicy {
268
266
  this.log(`[keepers] keeper-terminal could not be described: ${error instanceof Error ? error.message : String(error)}`);
269
267
  }
270
268
  }
271
- const legacy = this.options.legacy;
272
- if (legacy?.live)
273
- out.push({ live: true, current: false, status: this.legacyStatus(legacy) });
274
269
  return out;
275
270
  }
276
- legacyStatus(legacy) {
277
- const { state, open } = legacy.status();
278
- const shell = nameLabel(this.options.legacyShell?.(), 'shell');
279
- const sessions = legacy.lastSessions();
280
- return {
281
- kind: 'terminal', gen: 'legacy', legacy: true, state: state === 'present' || state === 'stopping' ? 'running' : 'unavailable', // busy = not answering
282
- protocolVersion: 0, buildVersion: 'legacy', replacement: 'required', pending: 'when-idle',
283
- metrics: { ptyOpen: open ?? sessions.filter((session) => !session.exited).length, ptyActive: 0, execRunning: 0 },
284
- items: sessions.slice(0, KEEPER_STATUS_MAX_ITEMS).map((session) => ({
285
- type: 'pty', id: clip(session.id, 256), label: shell, state: session.exited ? 'exited' : 'idle', sinceEpochMs: 0,
286
- })),
287
- };
288
- }
289
271
  async collectAcp() {
290
272
  const acp = this.options.acp;
291
273
  if (!acp)
@@ -351,7 +333,7 @@ export class KeeperPolicy {
351
333
  /** required/mandatory, never decided before (a cancel or a restart never brings an automatic decision back): queue it. */
352
334
  async automatic(sampled) {
353
335
  for (const { status, live, current } of sampled) {
354
- if (status.legacy || current || !live || !replacesByItself(status.replacement))
336
+ if (current || !live || !replacesByItself(status.replacement))
355
337
  continue;
356
338
  if (this.decisions.has(decisionKey(status.kind, status.gen)))
357
339
  continue;
@@ -375,7 +357,7 @@ export class KeeperPolicy {
375
357
  async runQueued(sampled) {
376
358
  const acpPending = [];
377
359
  for (const { status, live, current } of sampled) {
378
- if (status.legacy || current || this.pendingOf(status.kind, status.gen) !== 'when-idle')
360
+ if (current || this.pendingOf(status.kind, status.gen) !== 'when-idle')
379
361
  continue;
380
362
  if (status.kind === 'acp') {
381
363
  if (live)
@@ -465,8 +447,7 @@ export class KeeperPolicy {
465
447
  }
466
448
  async apply(frame) {
467
449
  const sampled = await this.sampled(true);
468
- const older = sampled.filter((entry) => entry.status.kind === frame.kind && !entry.current && entry.live && !entry.status.legacy);
469
- const legacy = frame.kind === 'terminal' ? sampled.find((entry) => entry.status.legacy) : undefined;
450
+ const older = sampled.filter((entry) => entry.status.kind === frame.kind && !entry.current && entry.live);
470
451
  const affected = [];
471
452
  let ok = true;
472
453
  let incomplete = false;
@@ -502,15 +483,6 @@ export class KeeperPolicy {
502
483
  error = `generation ${status.gen} could not be stopped`;
503
484
  }
504
485
  }
505
- if (legacy && this.options.legacy) {
506
- const shell = nameLabel(this.options.legacyShell?.(), 'shell');
507
- for (const session of await this.options.legacy.replaceNow())
508
- affected.push({ type: 'pty', id: session.id, label: shell, gen: 'legacy' });
509
- if (this.options.legacy.live) {
510
- ok = false;
511
- error = 'the legacy tools keeper could not be stopped';
512
- }
513
- }
514
486
  break;
515
487
  }
516
488
  case 'cancel': {
@@ -525,9 +497,8 @@ export class KeeperPolicy {
525
497
  }
526
498
  }
527
499
  this.sample = undefined;
528
- // From the decisions themselves (a sample in flight may predate them); the legacy keeper is always queued while it lives.
529
- const queued = [...this.decisions].some(([key, decision]) => key.startsWith(`${frame.kind}:`) && decision.mode === 'when-idle')
530
- || (frame.kind === 'terminal' && this.options.legacy?.live === true);
500
+ // Read decisions directly: an in-flight sample may predate them.
501
+ const queued = [...this.decisions].some(([key, decision]) => key.startsWith(`${frame.kind}:`) && decision.mode === 'when-idle');
531
502
  if (affected.length > KEEPER_AFFECTED_MAX_ITEMS)
532
503
  incomplete = true;
533
504
  return {
@@ -3,11 +3,14 @@
3
3
  import { promises as fs } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { readKeeperManifest } from '../kit/bundle.js';
6
- import { KeeperClient } from '../kit/client.js';
6
+ import { KeeperClient, KeeperNotConnectedError, KeeperRequestError } from '../kit/client.js';
7
+ import { liveManifest } from '../kit/lock.js';
7
8
  import { keeperGen } from '../kit/identity.js';
8
9
  import { defaultDistDir } from '../kit/launcher.js';
9
10
  import { protocolWindow } from '../kit/protocol.js';
11
+ import { RegistrationOwnershipError } from '../../runtime/ownership.js';
10
12
  import { PTY_CAPABILITY, PTY_UNAVAILABLE_ERROR, TERMINAL_PROTOCOL_VERSION, execStream, } from './protocol.js';
13
+ import { TERMINAL_GRID_LIMITS } from './gridProtocol.js';
11
14
  /** keeper-terminal could not be reached (launch, connect or hello failed): the request was never sent. */
12
15
  export class KeeperLaunchError extends Error {
13
16
  cause;
@@ -27,7 +30,12 @@ export function terminalStatusOf(hello) {
27
30
  const NOT_RUNNING_TTL_MS = 5000;
28
31
  /** The default bound of {@link TerminalKeeperClient.probe}: a real connect and hello, not a quick peek. */
29
32
  export const KEEPER_PROBE_TIMEOUT_MS = 3000;
30
- const errnoOf = (error) => typeof error === 'object' && error !== null && typeof error.code === 'string' ? error.code : undefined;
33
+ const errnoOf = (error) => {
34
+ // The kit keeps its public not_connected code; classify only the underlying socket errno.
35
+ const source = error instanceof KeeperNotConnectedError ? error.cause : error;
36
+ return typeof source === 'object' && source !== null && typeof source.code === 'string'
37
+ ? source.code : undefined;
38
+ };
31
39
  /** Connect errors that prove nothing listens: the socket file is gone, or a stale one with no listener behind it. */
32
40
  const ABSENT_ERRNOS = new Set(['ENOENT', 'ECONNREFUSED']);
33
41
  const messageOf = (error) => (error instanceof Error ? error.message : String(error));
@@ -48,7 +56,7 @@ export class TerminalKeeperClient {
48
56
  }
49
57
  get daemonProtocol() { return this.options.daemonProtocol ?? protocolWindow(TERMINAL_PROTOCOL_VERSION); }
50
58
  clientFor(paths) {
51
- if (this.client && this.clientGen === paths.gen)
59
+ if (this.client && this.client.state !== 'closed' && this.clientGen === paths.gen)
52
60
  return this.client;
53
61
  this.client?.close();
54
62
  this.client = new KeeperClient({
@@ -70,6 +78,7 @@ export class TerminalKeeperClient {
70
78
  * most one keeper per kind at a time. A `gen`-bound client only connects to its generation.
71
79
  */
72
80
  async ensure() {
81
+ this.options.launcher.assertOwnership?.('terminal', this.clientGen ?? this.options.gen);
73
82
  const current = this.client;
74
83
  if (this.options.gen !== undefined) {
75
84
  if (current && current.state === 'ready')
@@ -123,24 +132,41 @@ export class TerminalKeeperClient {
123
132
  return probing;
124
133
  }
125
134
  async probeOnce(timeoutMs) {
135
+ try {
136
+ this.options.launcher.assertOwnership?.('terminal', this.clientGen ?? this.options.gen);
137
+ }
138
+ catch {
139
+ return { state: 'unknown', reason: 'Registration owner needs verification' };
140
+ }
126
141
  const absent = () => { this.notRunningUntil = Date.now() + NOT_RUNNING_TTL_MS; return { state: 'absent' }; };
127
142
  let gen;
128
143
  try {
129
- gen = this.options.gen
144
+ gen = this.options.gen ?? this.currentGen
130
145
  ?? keeperGen((await readKeeperManifest(path.join(this.options.distDir ?? defaultDistDir(), 'terminal'))).buildVersion);
131
146
  }
132
- catch {
133
- return absent();
134
- } // no build of the current generation: nothing of it can run
147
+ catch (error) {
148
+ return errnoOf(error) === 'ENOENT' ? absent() : { state: 'unknown', reason: messageOf(error) };
149
+ } // A missing build is absent; unreadable or invalid metadata is inconclusive.
135
150
  const paths = this.options.launcher.pathsFor('terminal', gen);
136
151
  if (this.options.gen === undefined)
137
152
  this.currentGen = paths.gen;
153
+ // A live owner may be initializing, busy, or have lost its socket. None proves its sessions are gone.
154
+ const unreachable = async () => {
155
+ try {
156
+ if (await liveManifest(paths))
157
+ return { state: 'unknown', reason: 'live keeper owner is unreachable' };
158
+ return absent();
159
+ }
160
+ catch (error) {
161
+ return { state: 'unknown', reason: messageOf(error) };
162
+ }
163
+ };
138
164
  try {
139
165
  await fs.access(paths.tokenFile);
140
166
  await fs.access(paths.socket);
141
167
  }
142
168
  catch (error) {
143
- return ABSENT_ERRNOS.has(errnoOf(error) ?? '') ? absent() : { state: 'unknown', reason: messageOf(error) };
169
+ return ABSENT_ERRNOS.has(errnoOf(error) ?? '') ? unreachable() : { state: 'unknown', reason: messageOf(error) };
144
170
  }
145
171
  const client = this.clientFor(paths);
146
172
  let timer;
@@ -159,41 +185,105 @@ export class TerminalKeeperClient {
159
185
  return { state: 'unknown', reason: `protocol ${result.hello.protocolVersion} is outside the daemon's window` };
160
186
  }
161
187
  catch (error) {
162
- // The token or the socket went away meanwhile, or nothing listens behind a stale socket file: the keeper is gone.
163
- return ABSENT_ERRNOS.has(errnoOf(error) ?? '') ? absent() : { state: 'unknown', reason: messageOf(error) };
188
+ // Missing endpoints prove absence only when there is no verified live owner.
189
+ return ABSENT_ERRNOS.has(errnoOf(error) ?? '') ? unreachable() : { state: 'unknown', reason: messageOf(error) };
164
190
  }
165
191
  finally {
166
192
  clearTimeout(timer);
167
193
  }
168
194
  }
169
- /** `tools.info` terminal fields from a running keeper's hello, else from `fallback` (a cached node-pty check). Starts nothing. */
195
+ /** `tools.info` terminal fields from a running keeper's hello, else from the caller's unavailable status. Starts nothing. */
170
196
  async terminalStatus(fallback) {
171
197
  const hello = await this.runningHello();
172
198
  return hello ? terminalStatusOf(hello) : fallback();
173
199
  }
174
200
  /** A failure to reach the keeper (launch, connect, hello) surfaces as {@link KeeperLaunchError}: nothing was sent. */
175
201
  async call(method, params, options = {}) {
202
+ this.options.launcher.assertOwnership?.('terminal', this.clientGen ?? this.options.gen);
176
203
  let client;
177
204
  try {
178
205
  client = await this.ensure();
179
206
  }
180
207
  catch (error) {
208
+ if (error instanceof RegistrationOwnershipError)
209
+ throw error;
181
210
  throw new KeeperLaunchError(error);
182
211
  }
183
- return client.request(method, params, options);
212
+ const input = params;
213
+ const resource = method.startsWith('pty.') ? 'pty' : 'exec';
214
+ const id = resource === 'pty' ? input.id : input.execId;
215
+ if (this.options.launcher.ownership && typeof id === 'string' && method !== 'exec.status') {
216
+ let create = false;
217
+ if (method === 'pty.create')
218
+ create = !(await client.request('pty.list', {})).sessions.some(session => session.id === id);
219
+ if (method === 'exec.start')
220
+ create = !(await client.request('exec.list', {})).execs.some(exec => exec.execId === id);
221
+ this.options.launcher.assertResourceOwnership?.('terminal', this.clientGen, resource, id, create);
222
+ }
223
+ const result = await client.request(method, input, options);
224
+ if (this.options.launcher.ownership && typeof id === 'string' && method === 'exec.status') {
225
+ this.options.launcher.assertResourceOwnership?.('terminal', this.clientGen, resource, id);
226
+ }
227
+ if (this.options.launcher.ownership && (method === 'pty.list' || method === 'exec.list')) {
228
+ const registrationId = this.options.launcher.ownership.registrationId;
229
+ if (method === 'pty.list')
230
+ result.sessions = result.sessions.filter(session => !session.registrationId || session.registrationId === registrationId);
231
+ else
232
+ result.execs = result.execs.filter(exec => !exec.registrationId || exec.registrationId === registrationId);
233
+ const ids = method === 'pty.list' ? result.sessions.map(session => session.id) : result.execs.map(exec => exec.execId);
234
+ for (const current of ids)
235
+ this.options.launcher.assertResourceOwnership?.('terminal', this.clientGen, resource, current);
236
+ }
237
+ return result;
238
+ }
239
+ /** Connect only to existing terminal work. Never launch or retry a possibly delivered event. */
240
+ async callNative(method, params, absentResult) {
241
+ this.options.launcher.assertOwnership?.('terminal', this.clientGen ?? this.options.gen);
242
+ // A cached absence may predate another client's create; cleanup needs a fresh absence check.
243
+ const presence = this.hello ? { state: 'running' } : await this.probeOnce(KEEPER_PROBE_TIMEOUT_MS);
244
+ if (presence.state === 'absent') {
245
+ if (absentResult !== undefined)
246
+ return absentResult;
247
+ throw new KeeperRequestError('pty_not_found', 'Terminal session not found; keeper restart loses live PTYs');
248
+ }
249
+ if (presence.state === 'unknown')
250
+ throw new KeeperRequestError('pty_unavailable', `Terminal keeper unavailable: ${presence.reason}`);
251
+ const client = this.client;
252
+ if (!client)
253
+ throw new KeeperRequestError('pty_unavailable', 'Terminal keeper disconnected');
254
+ const input = params;
255
+ if (typeof input.id === 'string')
256
+ this.options.launcher.assertResourceOwnership?.('terminal', this.clientGen, 'pty', input.id);
257
+ return client.request(method, input);
258
+ }
259
+ create(params) { return this.call('pty.create', { ...params, ...(this.options.launcher.ownership ? { registrationId: this.options.launcher.ownership.registrationId } : {}) }); }
260
+ attach(params) { return this.callNative('pty.attach', params); }
261
+ poll(params) { return this.callNative('pty.poll', params); }
262
+ input(params) { return this.callNative('pty.input', params); }
263
+ resize(params) { return this.callNative('pty.resize', params); }
264
+ scroll(params) { return this.callNative('pty.scroll', params); }
265
+ detach(params) { return this.callNative('pty.detach', params, { ok: true }); }
266
+ close(params) { return this.callNative('pty.close', params, { ok: true }); }
267
+ /** Listing is a probe even if the ready socket disappears: reconnect, never launch. */
268
+ async list() {
269
+ const presence = await this.probe();
270
+ if (presence.state === 'absent')
271
+ return { schema: 1, limits: { ...TERMINAL_GRID_LIMITS }, sessions: [] };
272
+ if (presence.state === 'unknown')
273
+ throw new KeeperLaunchError(new Error(presence.reason));
274
+ const result = await this.client.request('pty.list', {});
275
+ const registrationId = this.options.launcher.ownership?.registrationId;
276
+ if (registrationId)
277
+ result.sessions = result.sessions.filter(session => !session.registrationId || session.registrationId === registrationId);
278
+ for (const session of result.sessions)
279
+ this.options.launcher.assertResourceOwnership?.('terminal', this.clientGen, 'pty', session.id);
280
+ return result;
184
281
  }
185
- create(params) { return this.call('pty.create', params); }
186
- attach(params) { return this.call('pty.attach', params); }
187
- poll(params) { return this.call('pty.poll', params); }
188
- write(params) { return this.call('pty.write', params); }
189
- resize(params) { return this.call('pty.resize', params); }
190
- takeover(params) { return this.call('pty.takeover', params); }
191
- detach(params) { return this.call('pty.detach', params); }
192
- close(params) { return this.call('pty.close', params); }
193
- list() { return this.call('pty.list', {}); }
194
282
  activity() { return this.call('keeper.activity', {}); }
195
283
  // exec.* (spec §4.2). The daemon decides argv/env/deadline/quotas; the keeper only executes them.
196
- execStart(params, options = {}) { return this.call('exec.start', params, options); }
284
+ execStart(params, options = {}) {
285
+ return this.call('exec.start', { ...params, ...(this.options.launcher.ownership ? { registrationId: this.options.launcher.ownership.registrationId } : {}) }, options);
286
+ }
197
287
  execList() { return this.call('exec.list', {}); }
198
288
  /** Descriptor plus the latest checkpoint (blob as given to `execCheckpoint`). */
199
289
  execStatus(execId, options = {}) { return this.call('exec.status', { execId }, options); }
@@ -206,7 +296,9 @@ export class TerminalKeeperClient {
206
296
  * resumes after the last record delivered.
207
297
  */
208
298
  async execSubscribe(execId, handler, options) {
209
- return (await this.ensure()).subscribe(execStream(execId), handler, options);
299
+ const client = await this.ensure();
300
+ this.options.launcher.assertResourceOwnership?.('terminal', this.clientGen, 'exec', execId);
301
+ return client.subscribe(execStream(execId), handler, options);
210
302
  }
211
303
  /** Drops the connection (the keeper and its PTYs keep running). */
212
304
  disconnect() { this.client?.close(); this.client = undefined; this.clientGen = undefined; }
@@ -44,7 +44,7 @@ const MAX_READ_RECORDS = 1024;
44
44
  const MAX_READ_BYTES = 1536 * 1024;
45
45
  const MAX_GRACE_MS = 10 * 60 * 1000;
46
46
  const EXEC_ID = /^[A-Za-z0-9_.:-]{1,128}$/;
47
- const START_KEYS = ['execId', 'argv', 'cwd', 'env', 'deadlineEpochMs', 'killGraceMs', 'retainHeadBytes', 'maxUncommittedBytes', 'onKeeperLoss', 'label', 'jobId'];
47
+ const START_KEYS = ['execId', 'argv', 'cwd', 'env', 'deadlineEpochMs', 'killGraceMs', 'retainHeadBytes', 'maxUncommittedBytes', 'onKeeperLoss', 'label', 'jobId', 'registrationId'];
48
48
  /**
49
49
  * Whether a kept record stays in keep.jsonl once the daemon committed through `committed`. The head reserve (kept `out`/`err`
50
50
  * records: the first `retainHeadBytes` of each stream) and the spawn record survive every non-final commit: the daemon shapes the
@@ -305,11 +305,14 @@ export class ExecHost {
305
305
  bad('Invalid jobId');
306
306
  if (this.runningCount() >= (this.options.runningLimit ?? EXEC_RUNNING_LIMIT))
307
307
  throw new KeeperMethodError(ExecErrorCode.limit, 'Too many running commands in keeper-terminal');
308
+ const registrationId = params.registrationId;
309
+ if (registrationId !== undefined && (typeof registrationId !== 'string' || !registrationId || registrationId.length > 128 || /[\x00-\x1f]/.test(registrationId)))
310
+ bad('Invalid registrationId');
308
311
  const meta = {
309
312
  v: 1, execId, label: params.label.slice(0, EXEC_LABEL_CHARS), ...(jobId ? { jobId: jobId } : {}),
310
313
  argv: argv, cwd, envKeys: Object.keys(env).sort(),
311
314
  deadlineEpochMs: typeof deadline === 'number' ? deadline : null, killGraceMs, retainHeadBytes, maxUncommittedBytes, onKeeperLoss: onKeeperLoss,
312
- createdAtMs: this.now(), owner: this.options.owner, spawn: null, exit: null, logReclaimed: null,
315
+ createdAtMs: this.now(), owner: this.options.owner, ...(typeof registrationId === 'string' ? { registrationId } : {}), spawn: null, exit: null, logReclaimed: null,
313
316
  };
314
317
  // Synchronously up to the spawn: a concurrent start of the same id finds the entry and waits for it.
315
318
  let entry;
@@ -811,7 +814,7 @@ export class ExecHost {
811
814
  describe(entry) {
812
815
  const { meta } = entry;
813
816
  return {
814
- execId: meta.execId, label: meta.label, ...(meta.jobId ? { jobId: meta.jobId } : {}), state: this.stateOf(entry),
817
+ execId: meta.execId, label: meta.label, ...(meta.registrationId ? { registrationId: meta.registrationId } : {}), ...(meta.jobId ? { jobId: meta.jobId } : {}), state: this.stateOf(entry),
815
818
  pid: meta.spawn?.pid ?? null, pgid: meta.spawn?.pgid ?? null, startTime: meta.spawn?.startTime ?? null,
816
819
  createdAtMs: meta.createdAtMs, deadlineEpochMs: meta.deadlineEpochMs, onKeeperLoss: meta.onKeeperLoss, exit: meta.exit,
817
820
  head: this.headOf(entry), committed: entry.committed, dropped: entry.dropped,
@@ -0,0 +1,73 @@
1
+ import { KeeperMethodError } from '../kit/protocol.js';
2
+ import { TERMINAL_GRID_LIMITS as limits } from './gridProtocol.js';
3
+ /** Complete immutable snapshots, with deltas computed against any retained base. */
4
+ export class TerminalFrames {
5
+ cache = [];
6
+ bytes = 0;
7
+ /** A failed/oversized capture leaves both the revision and recovery cache untouched. */
8
+ publish(snapshot, session, geometry) {
9
+ validate(snapshot, session);
10
+ const previous = this.cache.at(-1)?.frame;
11
+ const sameSession = previous?.incarnation === session.incarnation && previous.id === session.id;
12
+ const nextGeometry = geometry ?? (sameSession ? previous.geometry + Number(previous.cols !== snapshot.cols || previous.rows !== snapshot.rows) : 1);
13
+ if (!Number.isSafeInteger(nextGeometry) || nextGeometry < 1 || (sameSession && nextGeometry < previous.geometry))
14
+ throw new Error('Invalid terminal geometry');
15
+ const next = structuredClone({
16
+ ...snapshot, schema: 1, id: session.id, incarnation: session.incarnation,
17
+ revision: sameSession ? previous.revision + 1 : 1, baseRevision: 0,
18
+ geometry: nextGeometry, full: true, session,
19
+ });
20
+ if (sameSession && equalContent(previous, next))
21
+ return;
22
+ const bytes = resultBytes(next);
23
+ if (!sameSession) {
24
+ this.cache = [];
25
+ this.bytes = 0;
26
+ }
27
+ this.cache.push({ frame: next, bytes });
28
+ this.bytes += bytes;
29
+ while (this.cache.length > limits.maxCachedFrames || this.bytes > limits.maxFrameCacheBytes)
30
+ this.bytes -= this.cache.shift().bytes;
31
+ }
32
+ poll(after) {
33
+ const current = this.cache.at(-1)?.frame;
34
+ if (!current)
35
+ throw new Error('Terminal frame has not been captured');
36
+ if (after === current.revision)
37
+ return structuredClone({ ...current, full: false, baseRevision: after, styles: [], changedRows: [] });
38
+ const base = after === 0 ? undefined : this.cache.find(entry => entry.frame.revision === after)?.frame;
39
+ if (!base || base.geometry !== current.geometry || base.buffer !== current.buffer || base.incarnation !== current.incarnation)
40
+ return structuredClone(current);
41
+ const changedRows = current.changedRows.filter((row, index) => semanticRow(row, current) !== semanticRow(base.changedRows[index], base));
42
+ const delta = { ...current, full: false, baseRevision: after, changedRows };
43
+ resultBytes(delta);
44
+ return structuredClone(delta);
45
+ }
46
+ cacheStats() { return { frames: this.cache.length, bytes: this.bytes }; }
47
+ }
48
+ function semanticRow(row, frame) {
49
+ return JSON.stringify([row.wrapped ?? false, row.runs.flatMap(run => run.cells.map(cell => [cell.text, cell.width, frame.styles[run.style]]))]);
50
+ }
51
+ function equalContent(a, b) {
52
+ const header = (frame) => JSON.stringify([
53
+ frame.geometry, frame.cols, frame.rows, frame.buffer, frame.viewport, frame.cursor, frame.modes, frame.session,
54
+ ]);
55
+ return header(a) === header(b) && a.changedRows.every((row, index) => semanticRow(row, a) === semanticRow(b.changedRows[index], b));
56
+ }
57
+ function resultBytes(frame) {
58
+ const bytes = Buffer.byteLength(JSON.stringify(frame));
59
+ // Leave room for the keeper response envelope and the tool response wrapper.
60
+ if (bytes > limits.maxResultBytes - 1024)
61
+ throw new KeeperMethodError('pty_snapshot_too_large', 'Terminal frame exceeds result limit; resize to a smaller viewport');
62
+ return bytes;
63
+ }
64
+ function validate(snapshot, session) {
65
+ if (snapshot.cols < limits.minCols || snapshot.cols > limits.maxCols || snapshot.rows < limits.minRows || snapshot.rows > limits.maxRows
66
+ || snapshot.cols !== session.cols || snapshot.rows !== session.rows || snapshot.changedRows.length !== snapshot.rows)
67
+ throw new Error('Terminal snapshot must contain a complete matching grid');
68
+ for (const [index, row] of snapshot.changedRows.entries()) {
69
+ if (row.index !== index || row.runs.reduce((count, run) => count + run.cells.length, 0) !== snapshot.cols
70
+ || row.runs.some(run => !snapshot.styles[run.style] || run.cells.some(cell => typeof cell.text !== 'string' || ![0, 1, 2].includes(cell.width))))
71
+ throw new Error('Terminal snapshot must contain complete rows and styles');
72
+ }
73
+ }
@@ -2,11 +2,8 @@
2
2
  // CLI upgrade an OLDER generation can still host PTYs and execs while a newer one takes new work. Nothing here starts an older
3
3
  // generation: only the current build's client launches keeper-terminal. Exempt from the keeper-side import guard (daemon module).
4
4
  //
5
- // - New work (a `terminal.create`, an exec start) goes to the SERVING generation: the current build's, unless the replacement policy
6
- // (keepers/policy.ts) keeps an older, still compatible generation serving (`replacement: 'available'` and nobody asked to replace
7
- // it — spec §8.3: an available update never does anything by itself).
8
- // - Everything else is routed by OWNER: a PTY id or an exec id is served by the generation that runs it, through a client bound to
9
- // that generation (tools/terminalRouter.ts, exec/policy.ts).
5
+ // Durable exec work uses the serving picker and retains owner routing across generations.
6
+ // Native terminal tools bypass this selection and always use `primary` (tools/terminalRouter.ts).
10
7
  import path from 'node:path';
11
8
  import { readKeeperManifest } from '../kit/bundle.js';
12
9
  import { TerminalKeeperClient } from './client.js';
@@ -93,7 +90,7 @@ export class TerminalGenerations {
93
90
  }
94
91
  return older !== undefined && older !== current ? { gen: older, client: await this.client(older) } : { gen: current, client: this.primary };
95
92
  }
96
- /** `ExecKeeperGenerations.serving` / the router's create target. */
93
+ /** Durable exec creation target. Native terminal creation uses primary directly. */
97
94
  async serving() { return (await this.servingGeneration()).client; }
98
95
  /** Every live generation with its client (the current build's only when it runs). Starts nothing. */
99
96
  async running() {
@@ -109,9 +106,9 @@ export class TerminalGenerations {
109
106
  const serving = await this.serving();
110
107
  return (await this.running()).map((generation) => generation.client).filter((client) => client !== serving);
111
108
  }
112
- /** `tools.info` terminal fields from the serving generation when it already runs, else `fallback`. Starts nothing. */
109
+ /** Native terminal status belongs only to the current build. Starts nothing. */
113
110
  async terminalStatus(fallback) {
114
- return (await this.serving()).terminalStatus(fallback);
111
+ return this.primary.terminalStatus(fallback);
115
112
  }
116
113
  /** Drops the connection to `gen` (it was stopped). */
117
114
  forget(gen) {
@@ -0,0 +1,16 @@
1
+ /** Native terminal wire contract. No runtime/platform dependencies. */
2
+ export const TERMINAL_GRID_SCHEMA = 1;
3
+ export const TERMINAL_KEEPER_PROTOCOL = 2;
4
+ export const TERMINAL_GRID_CAPABILITY = 'pty-grid-v1';
5
+ export const TERMINAL_GRID_LIMITS = {
6
+ minCols: 20, maxCols: 300, minRows: 5, maxRows: 100,
7
+ maxSessions: 8, maxAttachments: 16, leaseMs: 15_000, scrollbackLines: 10_000,
8
+ maxInputBytes: 64 * 1024, maxRequestBytes: 128 * 1024, maxResultBytes: 2 * 1024 * 1024,
9
+ maxCachedFrames: 8, maxFrameCacheBytes: 8 * 1024 * 1024,
10
+ };
11
+ export const TERMINAL_MODIFIERS = { shift: 1, alt: 2, ctrl: 4, meta: 8 };
12
+ export const TERMINAL_STYLE_FLAGS = {
13
+ bold: 1, dim: 2, italic: 4, underline: 8, blink: 16, inverse: 32, invisible: 64, strike: 128,
14
+ };
15
+ export const TERMINAL_DEFAULT_COLOR = -1;
16
+ export const TERMINAL_RGB_COLOR_BASE = 0x1000000;