@platless/axom-cli 0.3.13 → 0.5.9

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 (235) hide show
  1. package/dist/axom-patch/cli/README.md +30 -8
  2. package/dist/axom-patch/cli/account-limits/README.md +27 -0
  3. package/dist/axom-patch/cli/account-limits/antigravity-proxy.mjs +47 -0
  4. package/dist/axom-patch/cli/account-limits/antigravity-read.mjs +90 -0
  5. package/dist/axom-patch/cli/account-limits/antigravity.mjs +46 -0
  6. package/dist/axom-patch/cli/account-limits/generate.py +120 -0
  7. package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +64 -0
  8. package/dist/axom-patch/cli/account-limits/normalize.mjs +151 -0
  9. package/dist/axom-patch/cli/claude-account-limits.json +37 -0
  10. package/dist/axom-patch/cli/codex-account-limits.json +37 -0
  11. package/dist/axom-patch/cli/commandcode-account-limits.json +30 -0
  12. package/dist/axom-patch/cli/nightly-task.md +152 -0
  13. package/dist/axom-patch/cli/pi-subagent-subwork.json +23 -0
  14. package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +5 -1
  15. package/dist/axom-patch/cli/probes/pi-subagent-subwork.mjs +211 -0
  16. package/dist/axom-patch/cli/probes/qoder-steering.mjs +1 -1
  17. package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +20 -8
  18. package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
  19. package/dist/axom-patch/cli/qoder-background-terminal.md +2 -1
  20. package/dist/axom-patch/cli/qoder-foreground-output.md +4 -3
  21. package/dist/axom-patch/cli/qoder-steering.json +15 -15
  22. package/dist/axom-patch/cli/qoder-subagent-origin.json +21 -21
  23. package/dist/axom-patch/cli/runtime.json +34 -8
  24. package/dist/axom-patch/cli/zcode-account-limits.json +31 -0
  25. package/dist/axom-patch/cli/zcode-models.json +38 -0
  26. package/dist/axom-patch/cli/zcode-subagent-subwork.json +73 -0
  27. package/dist/cli.js +33 -6
  28. package/dist/keepers/acp/keeper.mjs +3083 -0
  29. package/dist/keepers/acp/manifest.json +9 -0
  30. package/dist/keepers/terminal/keeper.mjs +8469 -0
  31. package/dist/keepers/terminal/manifest.json +9 -0
  32. package/dist/keepers/terminal/node_modules/node-pty/lib/conpty_console_list_agent.js +25 -0
  33. package/dist/keepers/terminal/node_modules/node-pty/lib/eventEmitter2.js +47 -0
  34. package/dist/keepers/terminal/node_modules/node-pty/lib/index.js +52 -0
  35. package/dist/keepers/terminal/node_modules/node-pty/lib/interfaces.js +7 -0
  36. package/dist/keepers/terminal/node_modules/node-pty/lib/shared/conout.js +10 -0
  37. package/dist/keepers/terminal/node_modules/node-pty/lib/terminal.js +190 -0
  38. package/dist/keepers/terminal/node_modules/node-pty/lib/types.js +7 -0
  39. package/dist/keepers/terminal/node_modules/node-pty/lib/unixTerminal.js +351 -0
  40. package/dist/keepers/terminal/node_modules/node-pty/lib/utils.js +38 -0
  41. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsConoutConnection.js +131 -0
  42. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsPtyAgent.js +344 -0
  43. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsTerminal.js +209 -0
  44. package/dist/keepers/terminal/node_modules/node-pty/lib/worker/conoutSocketWorker.js +22 -0
  45. package/dist/keepers/terminal/node_modules/node-pty/package.json +66 -0
  46. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
  47. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
  48. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/pty.node +0 -0
  49. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
  50. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-arm64/pty.node +0 -0
  51. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-x64/pty.node +0 -0
  52. package/dist/runtime-deps/qoder-sdk/package-lock.json +1189 -0
  53. package/dist/runtime-deps/qoder-sdk/package.json +8 -0
  54. package/dist/test/keeperKit.js +67 -0
  55. package/dist/workstation/acp/acpAgents.js +2163 -611
  56. package/dist/workstation/acp/acpClient.js +60 -497
  57. package/dist/workstation/acp/adoption.js +10 -396
  58. package/dist/workstation/acp/binaryInstall.js +100 -27
  59. package/dist/workstation/acp/builds/gc.js +245 -0
  60. package/dist/workstation/acp/builds/install/binary.js +28 -0
  61. package/dist/workstation/acp/builds/install/npm.js +235 -0
  62. package/dist/workstation/acp/builds/install/uv.js +254 -0
  63. package/dist/workstation/acp/builds/migrate.js +195 -0
  64. package/dist/workstation/acp/builds/model.js +128 -0
  65. package/dist/workstation/acp/builds/nodeGate.js +100 -0
  66. package/dist/workstation/acp/builds/store.js +352 -0
  67. package/dist/workstation/acp/commandTableProbe.js +68 -0
  68. package/dist/workstation/acp/configOptions.js +103 -1
  69. package/dist/workstation/acp/envSecrets.js +27 -0
  70. package/dist/workstation/acp/initializeResponse.js +285 -0
  71. package/dist/workstation/acp/installErrors.js +12 -2
  72. package/dist/workstation/acp/installProbe.js +1 -1
  73. package/dist/workstation/acp/managedPatches.js +76 -160
  74. package/dist/workstation/acp/outbox.js +330 -0
  75. package/dist/workstation/acp/patchAssets.js +20 -3
  76. package/dist/workstation/acp/permission.js +42 -10
  77. package/dist/workstation/acp/processor/checkpoint.js +87 -0
  78. package/dist/workstation/acp/processor/commands.js +35 -0
  79. package/dist/workstation/acp/processor/control.js +355 -0
  80. package/dist/workstation/acp/processor/emit.js +26 -0
  81. package/dist/workstation/acp/processor/jsonrpc.js +172 -0
  82. package/dist/workstation/acp/processor/memoryLog.js +49 -0
  83. package/dist/workstation/acp/processor/origins.js +48 -0
  84. package/dist/workstation/acp/processor/output.js +67 -0
  85. package/dist/workstation/acp/processor/projection.js +42 -0
  86. package/dist/workstation/acp/processor/record.js +128 -0
  87. package/dist/workstation/acp/processor/runtime.js +181 -0
  88. package/dist/workstation/acp/processor/sdkSchemas.js +61 -0
  89. package/dist/workstation/acp/processor/session.js +525 -0
  90. package/dist/workstation/acp/processor/sideChannel.js +80 -0
  91. package/dist/workstation/acp/processor/state.js +74 -0
  92. package/dist/workstation/acp/processor/step.js +459 -0
  93. package/dist/workstation/acp/processor/test/fixtureLog.js +65 -0
  94. package/dist/workstation/acp/processor/test/harness.js +120 -0
  95. package/dist/workstation/acp/processor/test/largeIdEcho.js +50 -0
  96. package/dist/workstation/acp/processor/test/rawAgent.js +70 -0
  97. package/dist/workstation/acp/processor/transport/generations.js +76 -0
  98. package/dist/workstation/acp/processor/transport/keeper.js +130 -0
  99. package/dist/workstation/acp/processor/transport/local.js +269 -0
  100. package/dist/workstation/acp/processor/transport/types.js +78 -0
  101. package/dist/workstation/acp/processor/updates.js +364 -0
  102. package/dist/workstation/acp/processorSession.js +1512 -0
  103. package/dist/workstation/acp/qoderStartup.js +1 -1
  104. package/dist/workstation/acp/registry/catalog.js +154 -32
  105. package/dist/workstation/acp/registry/launch.js +61 -119
  106. package/dist/workstation/acp/sessionUpdateMapper.js +18 -7
  107. package/dist/workstation/acp/steering.js +58 -0
  108. package/dist/workstation/acp/switching.js +114 -0
  109. package/dist/workstation/acp/terminalLogin.js +16 -42
  110. package/dist/workstation/acp/terminalLoginSpec.js +41 -0
  111. package/dist/workstation/acp/test/adapterFixtures.js +256 -0
  112. package/dist/workstation/acp/test/daemonLife.js +106 -0
  113. package/dist/workstation/acp/test/keeperWorld.js +267 -0
  114. package/dist/workstation/acp/test/memoryOutbox.js +55 -0
  115. package/dist/workstation/acp/test/raw/fixtures.js +196 -0
  116. package/dist/workstation/acp/test/raw/qoderCaptures.js +125 -0
  117. package/dist/workstation/acp/test/raw/sanitize.js +344 -0
  118. package/dist/workstation/acp/test/raw/sources.js +249 -0
  119. package/dist/workstation/acp/test/realAdapter.js +377 -0
  120. package/dist/workstation/acp/test/scriptAgent.js +206 -0
  121. package/dist/workstation/acp/test/sessionFrames.js +42 -0
  122. package/dist/workstation/acp/timedCommand.js +4 -2
  123. package/dist/workstation/acp/uvxInstall.js +72 -122
  124. package/dist/workstation/agentFlow.js +103 -0
  125. package/dist/workstation/agentSessions.js +1324 -474
  126. package/dist/workstation/channels/agent.js +7 -5
  127. package/dist/workstation/channels/computer.js +69 -21
  128. package/dist/workstation/channels/computerExecs.js +200 -0
  129. package/dist/workstation/channels/management.js +84 -4
  130. package/dist/workstation/daemon.js +288 -7
  131. package/dist/workstation/daemonctl.js +28 -2
  132. package/dist/workstation/exec/attach.js +126 -0
  133. package/dist/workstation/exec/command.js +40 -0
  134. package/dist/workstation/exec/durable.js +517 -0
  135. package/dist/workstation/exec/fallback.js +106 -0
  136. package/dist/workstation/exec/policy.js +837 -0
  137. package/dist/workstation/exec/shaping.js +211 -0
  138. package/dist/workstation/exec.js +3 -112
  139. package/dist/workstation/files/adapters/computerFileFrames.js +51 -0
  140. package/dist/workstation/files/adapters/toolRequests.js +121 -0
  141. package/dist/workstation/files/client.js +455 -0
  142. package/dist/workstation/files/errors.js +28 -0
  143. package/dist/workstation/files/fence.js +350 -0
  144. package/dist/workstation/files/find/fileIndex.js +153 -0
  145. package/dist/workstation/files/find/fuzzy.js +155 -0
  146. package/dist/workstation/files/host.js +122 -0
  147. package/dist/workstation/files/hostProtocol.js +2 -0
  148. package/dist/workstation/files/media/cache.js +620 -0
  149. package/dist/workstation/files/media/encoding.js +308 -0
  150. package/dist/workstation/files/media/estimator.js +100 -0
  151. package/dist/workstation/files/media/media.js +325 -0
  152. package/dist/workstation/files/media/packedAudio.js +23 -0
  153. package/dist/workstation/files/media/scheduler.js +551 -0
  154. package/dist/workstation/files/media/types.js +5 -0
  155. package/dist/workstation/files/scope.js +45 -0
  156. package/dist/workstation/files/structured.js +284 -0
  157. package/dist/workstation/{fileOps.js → files/text.js} +117 -38
  158. package/dist/workstation/files/uploads.js +351 -0
  159. package/dist/workstation/journal.js +65 -236
  160. package/dist/workstation/keepers/acp/client.js +101 -0
  161. package/dist/workstation/keepers/acp/envelope.js +62 -0
  162. package/dist/workstation/keepers/acp/host.js +419 -0
  163. package/dist/workstation/keepers/acp/main.js +18 -0
  164. package/dist/workstation/keepers/acp/memoryStream.js +121 -0
  165. package/dist/workstation/keepers/acp/params.js +137 -0
  166. package/dist/workstation/keepers/acp/protocol.js +66 -0
  167. package/dist/workstation/keepers/acp/recover.js +120 -0
  168. package/dist/workstation/keepers/acp/session.js +584 -0
  169. package/dist/workstation/keepers/acp/stderr.js +154 -0
  170. package/dist/workstation/keepers/kit/activity.js +103 -0
  171. package/dist/workstation/keepers/kit/bootstrap.js +164 -0
  172. package/dist/workstation/keepers/kit/buildVersion.js +51 -0
  173. package/dist/workstation/keepers/kit/bundle.js +120 -0
  174. package/dist/workstation/keepers/kit/client.js +432 -0
  175. package/dist/workstation/keepers/kit/identity.js +21 -0
  176. package/dist/workstation/keepers/kit/ipc.js +112 -0
  177. package/dist/workstation/keepers/kit/launchConfig.js +8 -0
  178. package/dist/workstation/keepers/kit/launcher.js +249 -0
  179. package/dist/workstation/keepers/kit/lock.js +123 -0
  180. package/dist/workstation/keepers/kit/orphanSweep.js +86 -0
  181. package/dist/workstation/keepers/kit/paths.js +75 -0
  182. package/dist/workstation/keepers/kit/protocol.js +43 -0
  183. package/dist/workstation/keepers/kit/server.js +518 -0
  184. package/dist/workstation/keepers/kit/streamLog.js +697 -0
  185. package/dist/workstation/keepers/legacy.js +296 -0
  186. package/dist/workstation/keepers/policy.js +576 -0
  187. package/dist/workstation/keepers/stop.js +34 -0
  188. package/dist/workstation/keepers/terminal/client.js +213 -0
  189. package/dist/workstation/keepers/terminal/exec.js +981 -0
  190. package/dist/workstation/keepers/terminal/generations.js +129 -0
  191. package/dist/workstation/keepers/terminal/keeper.js +62 -0
  192. package/dist/workstation/keepers/terminal/main.js +43 -0
  193. package/dist/workstation/keepers/terminal/orphanSweep.js +3 -0
  194. package/dist/workstation/keepers/terminal/params.js +27 -0
  195. package/dist/workstation/keepers/terminal/protocol.js +56 -0
  196. package/dist/workstation/keepers/terminal/pty.js +330 -0
  197. package/dist/workstation/protocol/accountLimits.js +47 -0
  198. package/dist/workstation/protocol/agent.js +48 -4
  199. package/dist/workstation/protocol/common.js +30 -0
  200. package/dist/workstation/protocol/computer.js +15 -4
  201. package/dist/workstation/protocol/management.js +50 -2
  202. package/dist/workstation/qoderAdapter/backgroundProcess.js +18 -6
  203. package/dist/workstation/qoderAdapter/backgroundTerminal.js +78 -147
  204. package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +372 -0
  205. package/dist/workstation/qoderAdapter/callbackContext.js +76 -0
  206. package/dist/workstation/qoderAdapter/foregroundOutput.js +15 -9
  207. package/dist/workstation/qoderAdapter/history.js +1 -3
  208. package/dist/workstation/qoderAdapter/main.js +4 -2
  209. package/dist/workstation/qoderAdapter/sdkDriver.js +1 -2
  210. package/dist/workstation/qoderAdapter/sdkRuntime.js +93 -0
  211. package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +145 -0
  212. package/dist/workstation/qoderAdapter/session.js +1 -1
  213. package/dist/workstation/qoderAdapter/subwork.js +15 -7
  214. package/dist/workstation/systemMetrics.js +110 -1
  215. package/dist/workstation/tools/client.js +9 -1
  216. package/dist/workstation/tools/info.js +50 -0
  217. package/dist/workstation/tools/keeperProcess.js +15 -5
  218. package/dist/workstation/tools/router.js +55 -0
  219. package/dist/workstation/tools/terminalRouter.js +222 -0
  220. package/dist/workstation/tools/validation.js +37 -1
  221. package/dist/workstation/update/coordinator.js +3 -0
  222. package/dist/workstation/update/ptyPreflight.js +21 -0
  223. package/dist/workstation/update/taskRegistry.js +105 -11
  224. package/package.json +10 -7
  225. package/dist/workstation/acp/acpSession.js +0 -1475
  226. package/dist/workstation/acp/contract.js +0 -499
  227. package/dist/workstation/acp/draftInterceptor.js +0 -163
  228. package/dist/workstation/acp/sessionScope.js +0 -19
  229. package/dist/workstation/filePaths.js +0 -174
  230. package/dist/workstation/fileWorker.js +0 -81
  231. package/dist/workstation/fileWorkerClient.js +0 -121
  232. package/dist/workstation/qoderAdapter/taskEvent.js +0 -26
  233. package/dist/workstation/tools/core.js +0 -50
  234. package/dist/workstation/tools/files.js +0 -525
  235. package/dist/workstation/tools/terminal.js +0 -214
@@ -0,0 +1,154 @@
1
+ // An adapter's stderr in keeper-acp (spec §5.3, §13; analysis A §5): read continuously (a full pipe would block the adapter),
2
+ // split into lines, redacted BEFORE anything is written anywhere, then (1) appended to a per-session rotating, size-capped file,
3
+ // (2) handed to the caller as an `err` record, and (3) kept as an 8 KiB tail for the `exit` record.
4
+ // Keeper-side module: node builtins and kit siblings only (the import guard enforces it).
5
+ import { closeSync, fstatSync, openSync, renameSync, writeSync } from 'node:fs';
6
+ import { StringDecoder } from 'node:string_decoder';
7
+ import { ACP_STDERR_FILE_BYTES, ACP_STDERR_LINE_BYTES, ACP_STDERR_TAIL_BYTES } from './protocol.js';
8
+ export const REDACTED = '[REDACTED]';
9
+ /** What replaces a line that was too long to keep (it is never cut: a cut could split a secret and leak its halves). */
10
+ export const omittedLine = (chars) => `[keeper-acp] stderr line of ${chars}+ characters omitted`;
11
+ /** A string as it appears inside a JSON string literal (what keeper-acp substitutes into a stdin line). */
12
+ export const jsonEscaped = (value) => JSON.stringify(value).slice(1, -1);
13
+ /** The JSON-escaped form with every non-ASCII character as `\uXXXX` (Python's `ensure_ascii`, many loggers). */
14
+ const asciiEscaped = (value) => jsonEscaped(value).replace(/[^\x00-\x7e]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
15
+ /**
16
+ * The strings to remove for one secret: the value itself, the token of a `Bearer <token>` value (today's rule,
17
+ * `acpClient.ts` `redactSecret`), each non-trivial line of a multi-line value (a line-based log can only ever hold one line of it),
18
+ * and of each of those the encodings an adapter plausibly logs: JSON-escaped (stdin receives that form, an adapter echoing its input
19
+ * prints it), JSON-escaped twice (that line logged inside another JSON string), and ASCII-only JSON escapes.
20
+ */
21
+ export function secretForms(secret) {
22
+ if (!secret)
23
+ return [];
24
+ const base = new Set([secret]);
25
+ if (secret.startsWith('Bearer ') && secret.length > 'Bearer '.length)
26
+ base.add(secret.slice('Bearer '.length));
27
+ if (/[\r\n]/.test(secret))
28
+ for (const line of secret.split(/\r?\n|\r/))
29
+ if (line.trim().length >= 8)
30
+ base.add(line);
31
+ const forms = new Set();
32
+ for (const form of base)
33
+ for (const encoded of [form, jsonEscaped(form), jsonEscaped(jsonEscaped(form)), asciiEscaped(form)])
34
+ if (encoded)
35
+ forms.add(encoded);
36
+ return [...forms];
37
+ }
38
+ export class StderrCapture {
39
+ options;
40
+ /** Longest first, so a secret that contains another is removed whole. */
41
+ secrets = [];
42
+ decoder = new StringDecoder('utf8');
43
+ partial = '';
44
+ dropping = false;
45
+ tailText = '';
46
+ fd;
47
+ fileSize = 0;
48
+ constructor(options) {
49
+ this.options = options;
50
+ }
51
+ /** Adds secrets to redact from now on (kept for the session's lifetime: a later spawn may print an earlier secret). */
52
+ addSecrets(values) {
53
+ const all = new Set(this.secrets);
54
+ for (const value of values)
55
+ for (const form of secretForms(value))
56
+ all.add(form);
57
+ this.secrets = [...all].sort((a, b) => b.length - a.length);
58
+ }
59
+ redact(text) {
60
+ let safe = text;
61
+ for (const secret of this.secrets)
62
+ if (safe.includes(secret))
63
+ safe = safe.replaceAll(secret, REDACTED);
64
+ return safe;
65
+ }
66
+ /** A new process: a fresh line buffer and tail (the tail belongs to one process's exit). */
67
+ restart() { this.decoder = new StringDecoder('utf8'); this.partial = ''; this.dropping = false; this.tailText = ''; }
68
+ get tail() { return this.tailText; }
69
+ push(chunk) {
70
+ let text = this.decoder.write(chunk);
71
+ for (;;) {
72
+ const newline = text.indexOf('\n');
73
+ if (newline < 0)
74
+ break;
75
+ const piece = text.slice(0, newline);
76
+ text = text.slice(newline + 1);
77
+ if (this.dropping) {
78
+ this.dropping = false;
79
+ this.partial = '';
80
+ continue;
81
+ }
82
+ this.line(this.partial + piece);
83
+ this.partial = '';
84
+ }
85
+ if (this.dropping)
86
+ return;
87
+ this.partial += text;
88
+ if (this.partial.length > ACP_STDERR_LINE_BYTES) {
89
+ this.emit(omittedLine(ACP_STDERR_LINE_BYTES));
90
+ this.partial = '';
91
+ this.dropping = true;
92
+ }
93
+ }
94
+ /** The stream ended: a final unterminated line is still a line. */
95
+ end() {
96
+ const rest = this.partial + this.decoder.end();
97
+ this.partial = '';
98
+ if (this.dropping) {
99
+ this.dropping = false;
100
+ return;
101
+ }
102
+ if (rest)
103
+ this.line(rest);
104
+ }
105
+ line(raw) {
106
+ this.emit(raw.length > ACP_STDERR_LINE_BYTES ? omittedLine(ACP_STDERR_LINE_BYTES) : this.redact(raw));
107
+ }
108
+ emit(safe) {
109
+ this.tailText = (this.tailText + safe + '\n').slice(-ACP_STDERR_TAIL_BYTES);
110
+ this.writeFile(`${safe}\n`);
111
+ try {
112
+ this.options.onLine(safe);
113
+ }
114
+ catch (error) {
115
+ this.options.onError?.(error);
116
+ }
117
+ }
118
+ writeFile(text) {
119
+ const file = this.options.file;
120
+ if (!file)
121
+ return;
122
+ try {
123
+ const data = Buffer.from(text, 'utf8');
124
+ const cap = this.options.fileBytes ?? ACP_STDERR_FILE_BYTES;
125
+ if (this.fd === undefined) {
126
+ this.fd = openSync(file, 'a', 0o600);
127
+ this.fileSize = fstatSync(this.fd).size;
128
+ }
129
+ if (this.fileSize > 0 && this.fileSize + data.length > cap) {
130
+ closeSync(this.fd);
131
+ this.fd = undefined;
132
+ renameSync(file, `${file}.1`);
133
+ this.fd = openSync(file, 'a', 0o600);
134
+ this.fileSize = 0;
135
+ }
136
+ let written = 0;
137
+ while (written < data.length)
138
+ written += writeSync(this.fd, data, written, data.length - written);
139
+ this.fileSize += data.length;
140
+ }
141
+ catch (error) {
142
+ this.options.onError?.(error);
143
+ }
144
+ }
145
+ close() {
146
+ if (this.fd !== undefined) {
147
+ try {
148
+ closeSync(this.fd);
149
+ }
150
+ catch { /* ignore */ }
151
+ this.fd = undefined;
152
+ }
153
+ }
154
+ }
@@ -0,0 +1,103 @@
1
+ /** Hard cap on `items` in one answer (spec §3.5). */
2
+ export const MAX_ACTIVITY_ITEMS = 32;
3
+ /** Longest `type`, `id`, `label` and `state` of an item; longer strings are cut. */
4
+ export const MAX_ACTIVITY_STRING = 256;
5
+ /** Metric names starting with this belong to the kit; a sampler's own are dropped. */
6
+ export const RESERVED_METRIC_PREFIX = 'kit.';
7
+ /** A sampler that has not answered by then is abandoned for this sample. */
8
+ export const DEFAULT_SAMPLE_TIMEOUT_MS = 2000;
9
+ const validItem = (value) => {
10
+ if (typeof value !== 'object' || value === null)
11
+ return false;
12
+ const item = value;
13
+ return typeof item.type === 'string' && typeof item.id === 'string' && typeof item.label === 'string'
14
+ && typeof item.state === 'string' && typeof item.sinceEpochMs === 'number' && Number.isFinite(item.sinceEpochMs);
15
+ };
16
+ const clip = (value) => (value.length > MAX_ACTIVITY_STRING ? value.slice(0, MAX_ACTIVITY_STRING) : value);
17
+ /** A sampler whose previous sample has not settled. A new sample joins it instead of calling the sampler again, so a hung sampler never piles up calls. */
18
+ const pending = new WeakMap();
19
+ async function runSampler(sampler, timeoutMs) {
20
+ let timer;
21
+ let sample = pending.get(sampler);
22
+ if (!sample) {
23
+ const started = Promise.resolve().then(sampler);
24
+ sample = started;
25
+ pending.set(sampler, started);
26
+ const release = () => { if (pending.get(sampler) === started)
27
+ pending.delete(sampler); };
28
+ started.then(release, release);
29
+ }
30
+ try {
31
+ return await Promise.race([
32
+ sample,
33
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('activity sampler timed out')), timeoutMs); timer.unref(); }),
34
+ ]);
35
+ }
36
+ finally {
37
+ clearTimeout(timer);
38
+ }
39
+ }
40
+ /**
41
+ * Samples every sampler now and merges the answers: metrics are summed per name (finite numbers only), items are
42
+ * concatenated in sampler order and cut at {@link MAX_ACTIVITY_ITEMS}; item strings (and an optional string `jobId`, the only
43
+ * extra field kept) are cut at {@link MAX_ACTIVITY_STRING}.
44
+ * A failing sampler is skipped and counted in `kit.sampleErrors`; cut items are counted in `kit.itemsOmitted`; a sampler metric
45
+ * in the reserved `kit.` namespace is dropped and counted in `kit.reservedDropped`. These kit metrics appear only when non-zero.
46
+ *
47
+ * Same-named metrics from different samplers are SUMMED. That is right for counts and wrong for gauges, so samplers
48
+ * must use distinct names for anything that is not additive.
49
+ */
50
+ export async function sampleActivity(samplers, options = {}) {
51
+ const timeoutMs = options.timeoutMs ?? DEFAULT_SAMPLE_TIMEOUT_MS;
52
+ const settled = await Promise.allSettled(samplers.map((sampler) => runSampler(sampler, timeoutMs)));
53
+ const metrics = {};
54
+ const items = [];
55
+ let errors = 0;
56
+ let omitted = 0;
57
+ let reserved = 0;
58
+ for (const result of settled) {
59
+ if (result.status === 'rejected') {
60
+ errors += 1;
61
+ try {
62
+ options.onError?.(result.reason);
63
+ }
64
+ catch { /* diagnostics must not matter */ }
65
+ continue;
66
+ }
67
+ const part = result.value;
68
+ if (typeof part !== 'object' || part === null)
69
+ continue;
70
+ if (typeof part.metrics === 'object' && part.metrics !== null) {
71
+ for (const [name, value] of Object.entries(part.metrics)) {
72
+ if (typeof value !== 'number' || !Number.isFinite(value))
73
+ continue;
74
+ if (name.startsWith(RESERVED_METRIC_PREFIX)) {
75
+ reserved += 1;
76
+ continue;
77
+ }
78
+ metrics[name] = (metrics[name] ?? 0) + value;
79
+ }
80
+ }
81
+ if (Array.isArray(part.items)) {
82
+ for (const item of part.items) {
83
+ if (!validItem(item))
84
+ continue;
85
+ if (items.length < MAX_ACTIVITY_ITEMS) {
86
+ const kept = { type: clip(item.type), id: clip(item.id), label: clip(item.label), state: clip(item.state), sinceEpochMs: item.sinceEpochMs };
87
+ if (typeof item.jobId === 'string' && item.jobId)
88
+ kept.jobId = clip(item.jobId);
89
+ items.push(kept);
90
+ }
91
+ else
92
+ omitted += 1;
93
+ }
94
+ }
95
+ }
96
+ if (errors > 0)
97
+ metrics['kit.sampleErrors'] = errors;
98
+ if (omitted > 0)
99
+ metrics['kit.itemsOmitted'] = omitted;
100
+ if (reserved > 0)
101
+ metrics['kit.reservedDropped'] = reserved;
102
+ return { metrics, items };
103
+ }
@@ -0,0 +1,164 @@
1
+ // The shared start-up of every keeper process (`keepers/<kind>/main.ts`): read the launch config, publish the pid manifest,
2
+ // listen with a fresh token, wire `keeper.stop` and SIGTERM/SIGINT to a clean exit, and report errors. A keeper main only
3
+ // says what it hosts (capabilities, methods, activity, what to do on stop). Keeper-side module: node builtins and kit siblings only.
4
+ import { readFileSync, unlinkSync } from 'node:fs';
5
+ import { decodeLaunchConfig } from './launchConfig.js';
6
+ import { manifestProcessLive, readPidManifest, writePidManifest } from './lock.js';
7
+ import { KeeperServer } from './server.js';
8
+ const STRING_FIELDS = ['identity', 'gen', 'unit', 'buildVersion', 'bundleDir', 'runDir', 'dataDir', 'socket', 'pidFile', 'tokenFile'];
9
+ /** The launch config from argv, refusing anything incomplete or meant for another kind. */
10
+ export function readLaunchConfig(argv, kind) {
11
+ const encoded = argv[2];
12
+ if (!encoded)
13
+ throw new Error('Missing keeper launch config argument');
14
+ let config;
15
+ try {
16
+ config = decodeLaunchConfig(encoded);
17
+ }
18
+ catch {
19
+ throw new Error('Unreadable keeper launch config');
20
+ }
21
+ if (typeof config !== 'object' || config === null)
22
+ throw new Error('Unreadable keeper launch config');
23
+ if (config.kind !== kind)
24
+ throw new Error(`Launch config is for keeper kind ${String(config.kind)}, not ${kind}`);
25
+ for (const field of STRING_FIELDS)
26
+ if (typeof config[field] !== 'string' || !config[field])
27
+ throw new Error(`Launch config lacks ${field}`);
28
+ if (!Number.isInteger(config.umask) || config.umask < 0 || config.umask > 0o777)
29
+ throw new Error('Launch config lacks umask');
30
+ return config;
31
+ }
32
+ const defaultLog = (kind) => (line) => {
33
+ try {
34
+ process.stderr.write(`${new Date().toISOString()} keeper-${kind}: ${line}\n`);
35
+ }
36
+ catch { /* no stderr */ }
37
+ };
38
+ const unlinkIf = (file, owned) => {
39
+ try {
40
+ if (owned())
41
+ unlinkSync(file);
42
+ }
43
+ catch { /* already gone or unreadable: not ours to remove */ }
44
+ };
45
+ /**
46
+ * Starts a keeper process. Order matters:
47
+ * 0. (process hooks) the daemon's umask from the launch config becomes the process umask, so PTYs, execs and files follow it;
48
+ * 1. the pid manifest is written before anything else initialises, so a launcher probing a slow start sees a live keeper
49
+ * (and never unlinks its socket); a manifest naming another live keeper of this generation makes this start give up;
50
+ * 2. the keeper module is created and installed;
51
+ * 3. the server listens (token published only once the socket is ours) under a private 077 umask, restored to the daemon's
52
+ * umask afterwards;
53
+ * 4. `keeper.stop` and SIGTERM/SIGINT stop through the kit's stop path; the stop runs the module's shutdown, removes the
54
+ * pid manifest and token (only if still ours), closes the socket and exits 0.
55
+ * A start that fails exits 1 after removing what it published. Uncaught errors are logged, never fatal: a keeper
56
+ * hosts live user sessions, and dying on an unexpected exception would lose every one of them.
57
+ */
58
+ export async function bootstrapKeeper(options) {
59
+ const log = options.log ?? defaultLog(options.kind);
60
+ const exit = options.exit ?? ((code) => process.exit(code));
61
+ const report = (error) => log(error instanceof Error ? (error.stack ?? error.message) : String(error));
62
+ const hooks = options.processHooks !== false;
63
+ const config = readLaunchConfig(options.argv ?? process.argv, options.kind);
64
+ const ownsManifest = () => { try {
65
+ return JSON.parse(readFileSync(config.pidFile, 'utf8')).pid === process.pid;
66
+ }
67
+ catch {
68
+ return false;
69
+ } };
70
+ if (hooks) {
71
+ // The daemon's umask, process-wide: under a systemd transient unit the inherited one is the user manager's, not the user's.
72
+ process.umask(config.umask);
73
+ // Never hold the daemon's working directory (a removable volume, a deleted worktree) for the keeper's whole life.
74
+ try {
75
+ process.chdir(config.runDir);
76
+ }
77
+ catch (error) {
78
+ report(error);
79
+ }
80
+ process.on('uncaughtException', (error) => report(error));
81
+ process.on('unhandledRejection', (error) => report(error));
82
+ process.on('SIGHUP', () => { }); // detached and without a controlling terminal; never let a stray hangup end the keeper
83
+ }
84
+ const previous = await readPidManifest(config.pidFile).catch(() => null);
85
+ if (previous && previous.pid !== process.pid && previous.identity === config.identity && previous.gen === config.gen && await manifestProcessLive(previous))
86
+ throw new Error(`Keeper ${options.kind} ${config.gen} already runs as pid ${previous.pid}`);
87
+ await writePidManifest(config.pidFile, {
88
+ kind: config.kind, gen: config.gen, buildVersion: config.buildVersion, bundleDir: config.bundleDir, startedAt: Date.now(), identity: config.identity, pid: process.pid,
89
+ });
90
+ let finishStop;
91
+ const stopped = new Promise((resolve) => { finishStop = resolve; });
92
+ let module;
93
+ let server;
94
+ let token = '';
95
+ let stopping = false;
96
+ const onStop = (mode) => {
97
+ if (stopping)
98
+ return;
99
+ stopping = true;
100
+ void (async () => {
101
+ try {
102
+ if (module?.shutdown) {
103
+ let timer;
104
+ await Promise.race([
105
+ Promise.resolve().then(() => module.shutdown(mode)),
106
+ new Promise((resolve) => { timer = setTimeout(resolve, options.shutdownTimeoutMs ?? 5000); timer.unref(); }),
107
+ ]).catch(report).finally(() => clearTimeout(timer));
108
+ }
109
+ }
110
+ finally {
111
+ unlinkIf(config.pidFile, ownsManifest);
112
+ unlinkIf(config.tokenFile, () => token !== '' && readFileSync(config.tokenFile, 'utf8') === token);
113
+ await server.close().catch(report);
114
+ finishStop();
115
+ exit(0);
116
+ }
117
+ })();
118
+ };
119
+ try {
120
+ module = await options.create(config, { log });
121
+ server = new KeeperServer({
122
+ kind: options.kind, identity: config.identity, gen: config.gen, buildVersion: config.buildVersion, protocolVersion: options.protocolVersion,
123
+ socket: config.socket, tokenFile: config.tokenFile, capabilities: module.capabilities ?? [], onError: report, onStop, ...options.server,
124
+ });
125
+ module.install(server);
126
+ const umask = hooks ? process.umask(0o077) : undefined; // process-wide; in-process (tests, workers) the socket's 0700 directory suffices
127
+ try {
128
+ await server.listen();
129
+ }
130
+ finally {
131
+ if (umask !== undefined)
132
+ process.umask(umask);
133
+ }
134
+ token = server.token;
135
+ }
136
+ catch (error) {
137
+ unlinkIf(config.pidFile, ownsManifest);
138
+ try {
139
+ await module?.shutdown?.('force');
140
+ }
141
+ catch (shutdownError) {
142
+ report(shutdownError);
143
+ }
144
+ throw error;
145
+ }
146
+ const stop = (mode) => server.stopped(mode);
147
+ if (hooks) {
148
+ process.on('SIGTERM', () => stop('graceful'));
149
+ process.on('SIGINT', () => stop('graceful'));
150
+ }
151
+ log(`listening (pid ${process.pid}, build ${config.buildVersion.slice(0, 12)}, protocol ${options.protocolVersion})`);
152
+ return { config, server, stop, stopped };
153
+ }
154
+ /** `bootstrapKeeper` for a keeper process's main: a failed start is logged and exits 1. */
155
+ export async function runKeeperMain(options) {
156
+ try {
157
+ return await bootstrapKeeper(options);
158
+ }
159
+ catch (error) {
160
+ (options.log ?? defaultLog(options.kind))(`start failed: ${error instanceof Error ? error.message : String(error)}`);
161
+ (options.exit ?? ((code) => process.exit(code)))(1);
162
+ return undefined;
163
+ }
164
+ }
@@ -0,0 +1,51 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
3
+ import { join, relative, sep } from 'node:path';
4
+ /**
5
+ * The keeper buildVersion: sha256 over the bundle bytes plus the sorted list of
6
+ * (relative path, executable flag, sha256) of every file under `node_modules/`.
7
+ *
8
+ * This is the single implementation of the hash. `scripts/bundle-keepers.mjs` imports the compiled module to
9
+ * stamp `manifest.json`, and `ensureBundle` re-runs it over the copied snapshot, so the two cannot drift.
10
+ * The snapshot is a full copy of `dist/keepers/<kind>` (all darwin/linux prebuilds, ~600 KiB), because the
11
+ * hash covers every native file the build carried; copying only the current platform would not verify.
12
+ *
13
+ * It depends on nothing else (no package version, timestamps or absolute paths), so shipping a CLI with
14
+ * unchanged keeper code does not change it.
15
+ */
16
+ const sha256 = (data) => createHash('sha256').update(data).digest('hex');
17
+ function walkFiles(dir) {
18
+ const out = [];
19
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
20
+ const full = join(dir, entry.name);
21
+ if (entry.isDirectory())
22
+ out.push(...walkFiles(full));
23
+ else
24
+ out.push(full);
25
+ }
26
+ return out;
27
+ }
28
+ /** Relative (posix) paths of the native files carried next to a bundle, sorted. */
29
+ export function nativeFilesOf(kindDir) {
30
+ let files;
31
+ try {
32
+ files = walkFiles(join(kindDir, 'node_modules'));
33
+ }
34
+ catch (error) {
35
+ if (error.code === 'ENOENT')
36
+ return [];
37
+ throw error;
38
+ }
39
+ return files.map((file) => relative(kindDir, file).split(sep).join('/')).sort();
40
+ }
41
+ export function computeBuildVersion(kindDir) {
42
+ const hash = createHash('sha256');
43
+ const bundle = readFileSync(join(kindDir, 'keeper.mjs'));
44
+ hash.update(`bundle ${bundle.length}\n`).update(bundle);
45
+ for (const rel of nativeFilesOf(kindDir)) {
46
+ const file = join(kindDir, rel);
47
+ const executable = (statSync(file).mode & 0o111) !== 0 ? 'x' : '-';
48
+ hash.update(`\nnative ${rel} ${executable} ${sha256(readFileSync(file))}`);
49
+ }
50
+ return hash.digest('hex');
51
+ }
@@ -0,0 +1,120 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmodSync, cpSync, promises as fs, readdirSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { computeBuildVersion } from './buildVersion.js';
5
+ import { KEEPER_KINDS } from './identity.js';
6
+ import { manifestProcessLive, readPidManifest } from './lock.js';
7
+ export class BundleIntegrityError extends Error {
8
+ constructor(message) { super(message); this.name = 'BundleIntegrityError'; }
9
+ }
10
+ const BUILD_VERSION = /^[0-9a-f]{64}$/;
11
+ export async function readKeeperManifest(kindDir) {
12
+ const value = JSON.parse(await fs.readFile(path.join(kindDir, 'manifest.json'), 'utf8'));
13
+ const range = value.daemonProtocol;
14
+ if (!KEEPER_KINDS.includes(value.kind) || !Number.isInteger(value.protocolVersion) || typeof value.buildVersion !== 'string'
15
+ || !BUILD_VERSION.test(value.buildVersion) || !Array.isArray(range) || range.length !== 2 || !range.every(Number.isInteger))
16
+ throw new BundleIntegrityError(`Invalid keeper manifest in ${kindDir}`);
17
+ return value;
18
+ }
19
+ /** Throws unless the files under `dir` hash to `buildVersion` (the same hash `bundle-keepers.mjs` stamped). */
20
+ function verify(dir, buildVersion, what) {
21
+ let actual;
22
+ try {
23
+ actual = computeBuildVersion(dir);
24
+ }
25
+ catch (error) {
26
+ throw new BundleIntegrityError(`${what} is unreadable: ${error.message}`);
27
+ }
28
+ if (actual !== buildVersion)
29
+ throw new BundleIntegrityError(`${what} does not match keeper build ${buildVersion.slice(0, 12)}`);
30
+ }
31
+ /** Copies preserving modes explicitly: spawn-helper must stay executable whatever the copy primitive does. */
32
+ function copyTree(source, target) {
33
+ cpSync(source, target, { recursive: true, dereference: true });
34
+ const walk = (from, to) => {
35
+ for (const entry of readdirSync(from, { withFileTypes: true })) {
36
+ const src = path.join(from, entry.name);
37
+ const dst = path.join(to, entry.name);
38
+ if (entry.isDirectory())
39
+ walk(src, dst);
40
+ else
41
+ chmodSync(dst, statSync(src).mode & 0o777);
42
+ }
43
+ };
44
+ walk(source, target);
45
+ }
46
+ /**
47
+ * Makes `bundlesDir/<buildVersion>` an immutable snapshot of `distKindDir` and returns it. The keeper runs from
48
+ * the snapshot, so replacing `dist/keepers` (npm upgrade, rebuild) never changes a running keeper's files.
49
+ *
50
+ * Copy to `.tmp-<uuid>`, recompute the hash over the copy (all of it: bundle plus every native file the build
51
+ * carried) and compare with the manifest, then rename. A snapshot that already exists is re-verified instead of
52
+ * trusted, and a mismatch throws: the caller must not launch from it.
53
+ */
54
+ export async function ensureBundle(distKindDir, bundlesDir) {
55
+ const manifest = await readKeeperManifest(distKindDir);
56
+ const target = path.join(bundlesDir, manifest.buildVersion);
57
+ const existing = await fs.lstat(target).then((stat) => stat, (error) => {
58
+ if (error.code === 'ENOENT')
59
+ return null;
60
+ throw error;
61
+ });
62
+ if (existing) {
63
+ if (!existing.isDirectory() || existing.isSymbolicLink())
64
+ throw new BundleIntegrityError(`${target} is not a keeper bundle directory`);
65
+ verify(target, manifest.buildVersion, `Existing keeper bundle ${target}`);
66
+ return { manifest, buildVersion: manifest.buildVersion, bundleDir: target, created: false };
67
+ }
68
+ await fs.mkdir(bundlesDir, { recursive: true, mode: 0o700 });
69
+ const temp = path.join(bundlesDir, `.tmp-${randomUUID()}`);
70
+ try {
71
+ copyTree(distKindDir, temp);
72
+ verify(temp, manifest.buildVersion, `Keeper bundle copy of ${distKindDir}`);
73
+ try {
74
+ await fs.rename(temp, target);
75
+ }
76
+ catch (error) {
77
+ const code = error.code;
78
+ if (code !== 'ENOTEMPTY' && code !== 'EEXIST')
79
+ throw error;
80
+ // A concurrent ensure won the rename. Its snapshot must be as good as ours.
81
+ verify(target, manifest.buildVersion, `Existing keeper bundle ${target}`);
82
+ return { manifest, buildVersion: manifest.buildVersion, bundleDir: target, created: false };
83
+ }
84
+ }
85
+ finally {
86
+ await fs.rm(temp, { recursive: true, force: true });
87
+ }
88
+ return { manifest, buildVersion: manifest.buildVersion, bundleDir: target, created: true };
89
+ }
90
+ /**
91
+ * Deletes `bundles/*` that no live keeper's pid manifest references (a pid reused by an unrelated process does not count) (and stale `.tmp-*` leftovers).
92
+ * Returns the removed paths.
93
+ */
94
+ export async function gcBundles(options) {
95
+ const minAge = options.minAgeMs ?? 10 * 60_000;
96
+ const now = options.now ?? Date.now();
97
+ const keep = new Set(options.keep ?? []);
98
+ const removed = [];
99
+ for (const kind of KEEPER_KINDS) {
100
+ const root = path.join(path.resolve(options.configDir), 'keepers', kind);
101
+ const referenced = new Set(keep);
102
+ for (const gen of await fs.readdir(path.join(root, 'run')).catch(() => [])) {
103
+ const manifest = await readPidManifest(path.join(root, 'run', gen, 'keeper.pid.json')).catch(() => null);
104
+ if (manifest && await manifestProcessLive(manifest))
105
+ referenced.add(manifest.buildVersion);
106
+ }
107
+ const bundles = path.join(root, 'bundles');
108
+ for (const name of await fs.readdir(bundles).catch(() => [])) {
109
+ if (referenced.has(name))
110
+ continue;
111
+ const dir = path.join(bundles, name);
112
+ const stat = await fs.lstat(dir).catch(() => null);
113
+ if (!stat || (minAge > 0 && now - Math.max(stat.mtimeMs, stat.ctimeMs) < minAge))
114
+ continue;
115
+ await fs.rm(dir, { recursive: true, force: true });
116
+ removed.push(dir);
117
+ }
118
+ }
119
+ return removed;
120
+ }