@fitchmultz/pi-subagents 0.50.0 → 0.52.0

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 (180) hide show
  1. package/CHANGELOG.md +63 -2
  2. package/README.md +36 -5
  3. package/dist/agent/child-runtime.js +80 -0
  4. package/dist/agent/contracts.js +15 -24
  5. package/dist/agent/host.js +3 -16
  6. package/dist/agent/index.js +212 -192
  7. package/dist/agent/journal-tail.js +115 -156
  8. package/dist/agent/launch.js +20 -8
  9. package/dist/agent/legacy.js +65 -23
  10. package/dist/agent/notifications.js +138 -47
  11. package/dist/agent/observation-state.js +246 -0
  12. package/dist/agent/observation-view.js +258 -0
  13. package/dist/agent/observation.js +350 -0
  14. package/dist/agent/observe-card.js +243 -0
  15. package/dist/agent/observe-chrome.js +85 -0
  16. package/dist/agent/observe-client.js +285 -0
  17. package/dist/agent/observe-layout.js +65 -0
  18. package/dist/agent/observe-reading.js +84 -0
  19. package/dist/agent/observe-screen.js +400 -96
  20. package/dist/agent/observe-transcript.js +382 -0
  21. package/dist/agent/observe.js +250 -43
  22. package/dist/agent/parking.js +253 -0
  23. package/dist/agent/process.js +228 -28
  24. package/dist/agent/receipts.js +92 -20
  25. package/dist/agent/runner.js +229 -126
  26. package/dist/agent/shell.js +2 -1
  27. package/dist/agent/storage.js +47 -12
  28. package/dist/agent/tool-schemas.js +20 -0
  29. package/dist/agent/typebox.js +10 -5
  30. package/dist/agent/usage.js +0 -115
  31. package/dist/agent/view.js +46 -16
  32. package/dist/agent/wake.js +99 -0
  33. package/dist/build-info.js +1 -1
  34. package/dist/intercom-client.d.ts +26 -0
  35. package/dist/intercom-client.js +45 -0
  36. package/dist/pi-intercom/agent-dir.d.ts +1 -0
  37. package/dist/pi-intercom/bridge-session.js +2 -1
  38. package/dist/pi-intercom/broker/broker.js +62 -43
  39. package/dist/pi-intercom/broker/client-last.d.ts +24 -0
  40. package/dist/pi-intercom/broker/client-last.js +42 -5
  41. package/dist/pi-intercom/broker/client-park.d.ts +9 -0
  42. package/dist/pi-intercom/broker/client-park.js +21 -0
  43. package/dist/pi-intercom/broker/client.d.ts +59 -0
  44. package/dist/pi-intercom/broker/client.js +55 -29
  45. package/dist/pi-intercom/broker/connection.d.ts +29 -0
  46. package/dist/pi-intercom/broker/framing.d.ts +17 -0
  47. package/dist/pi-intercom/broker/framing.js +10 -7
  48. package/dist/pi-intercom/broker/guard.js +23 -0
  49. package/dist/pi-intercom/broker/last.d.ts +30 -0
  50. package/dist/pi-intercom/broker/last.js +87 -40
  51. package/dist/pi-intercom/broker/parked.js +107 -0
  52. package/dist/pi-intercom/broker/paths.d.ts +13 -0
  53. package/dist/pi-intercom/broker/paths.js +35 -12
  54. package/dist/pi-intercom/broker/pid.js +5 -1
  55. package/dist/pi-intercom/broker/server-connection.js +20 -1
  56. package/dist/pi-intercom/broker/snapshot-reader.d.ts +9 -0
  57. package/dist/pi-intercom/broker/snapshots.d.ts +29 -0
  58. package/dist/pi-intercom/broker/targets.d.ts +9 -0
  59. package/dist/pi-intercom/connection.js +71 -20
  60. package/dist/pi-intercom/identity.js +6 -5
  61. package/dist/pi-intercom/inbound-delivery.js +16 -60
  62. package/dist/pi-intercom/inbound-journal.js +83 -6
  63. package/dist/pi-intercom/inbound-record.js +18 -1
  64. package/dist/pi-intercom/inbound.js +22 -37
  65. package/dist/pi-intercom/index.js +41 -0
  66. package/dist/pi-intercom/last-message.js +4 -16
  67. package/dist/pi-intercom/lifecycle.js +88 -3
  68. package/dist/pi-intercom/outbound.js +5 -15
  69. package/dist/pi-intercom/reply-tracker.js +7 -6
  70. package/dist/pi-intercom/reply-wait.js +2 -2
  71. package/dist/pi-intercom/session-targets.d.ts +24 -0
  72. package/dist/pi-intercom/tool-registration.js +4 -1
  73. package/dist/pi-intercom/tool-results.js +5 -7
  74. package/dist/pi-intercom/tool-schemas.js +12 -26
  75. package/dist/pi-intercom/types.d.ts +212 -0
  76. package/dist/pi-intercom/types.js +54 -3
  77. package/dist/pi-intercom/validation.d.ts +12 -0
  78. package/dist/pi-intercom/validation.js +3 -0
  79. package/dist/shared/bounded-json.js +151 -0
  80. package/dist/shared/compact-cards.js +66 -0
  81. package/dist/shared/journal-frames.js +3 -6
  82. package/dist/shared/json-projection.js +267 -129
  83. package/dist/shared/native-journal.js +123 -56
  84. package/dist/shared/parking.js +12 -0
  85. package/dist/shared/post-exit-stdio-guard.js +120 -7
  86. package/dist/shared/tui-action.js +29 -0
  87. package/dist/shared/unknown.d.ts +9 -0
  88. package/docs/code-quality.md +5 -3
  89. package/docs/development.md +42 -3
  90. package/docs/intercom-bridge.md +9 -4
  91. package/docs/intercom.md +228 -7
  92. package/docs/performance.md +118 -14
  93. package/docs/reference.md +168 -20
  94. package/docs/retired-history-maintenance.md +252 -0
  95. package/package.json +28 -15
  96. package/scripts/archive-retired-history.mjs +464 -0
  97. package/scripts/build.mjs +48 -28
  98. package/scripts/measure-size.mjs +1 -1
  99. package/scripts/native-package-smoke.mjs +153 -12
  100. package/scripts/package-smoke.mjs +80 -11
  101. package/scripts/prepare.mjs +2 -2
  102. package/scripts/real-pi-live.mjs +377 -0
  103. package/scripts/real-pi-processes.mjs +6 -1
  104. package/scripts/real-pi-smoke.mjs +6 -282
  105. package/scripts/run-tests.mjs +4 -2
  106. package/scripts/typescript.mjs +35 -0
  107. package/skills/pi-intercom/SKILL.md +2 -1
  108. package/skills/pi-subagents/SKILL.md +23 -5
  109. package/src/agent/child-runtime.ts +105 -0
  110. package/src/agent/contracts.ts +18 -34
  111. package/src/agent/host.ts +39 -20
  112. package/src/agent/index.ts +234 -207
  113. package/src/agent/journal-tail.ts +141 -153
  114. package/src/agent/launch.ts +33 -9
  115. package/src/agent/legacy.ts +85 -31
  116. package/src/agent/notifications.ts +192 -67
  117. package/src/agent/observation-state.ts +277 -0
  118. package/src/agent/observation-view.ts +267 -0
  119. package/src/agent/observation.ts +409 -0
  120. package/src/agent/observe-card.ts +304 -0
  121. package/src/agent/observe-chrome.ts +170 -0
  122. package/src/agent/observe-client.ts +318 -0
  123. package/src/agent/observe-layout.ts +79 -0
  124. package/src/agent/observe-reading.ts +97 -0
  125. package/src/agent/observe-screen.ts +443 -129
  126. package/src/agent/observe-transcript.ts +434 -0
  127. package/src/agent/observe.ts +304 -71
  128. package/src/agent/parking.ts +341 -0
  129. package/src/agent/process.ts +280 -37
  130. package/src/agent/receipts.ts +114 -24
  131. package/src/agent/runner.ts +227 -149
  132. package/src/agent/shell.ts +2 -1
  133. package/src/agent/storage.ts +44 -11
  134. package/src/agent/tool-schemas.ts +33 -0
  135. package/src/agent/typebox.ts +12 -7
  136. package/src/agent/usage.ts +0 -130
  137. package/src/agent/view.ts +70 -20
  138. package/src/agent/wake.ts +107 -0
  139. package/src/intercom-client.ts +108 -0
  140. package/src/pi-intercom/bridge-session.ts +2 -2
  141. package/src/pi-intercom/broker/broker.ts +64 -53
  142. package/src/pi-intercom/broker/client-last.ts +65 -12
  143. package/src/pi-intercom/broker/client-park.ts +23 -0
  144. package/src/pi-intercom/broker/client.ts +63 -45
  145. package/src/pi-intercom/broker/framing.ts +11 -8
  146. package/src/pi-intercom/broker/guard.ts +33 -0
  147. package/src/pi-intercom/broker/last.ts +131 -43
  148. package/src/pi-intercom/broker/parked.ts +130 -0
  149. package/src/pi-intercom/broker/paths.ts +39 -12
  150. package/src/pi-intercom/broker/pid.ts +6 -1
  151. package/src/pi-intercom/broker/server-connection.ts +27 -2
  152. package/src/pi-intercom/connection.ts +103 -39
  153. package/src/pi-intercom/identity.ts +8 -5
  154. package/src/pi-intercom/inbound-delivery.ts +20 -66
  155. package/src/pi-intercom/inbound-journal.ts +103 -7
  156. package/src/pi-intercom/inbound-record.ts +21 -4
  157. package/src/pi-intercom/inbound.ts +32 -49
  158. package/src/pi-intercom/index.ts +44 -0
  159. package/src/pi-intercom/last-message.ts +11 -20
  160. package/src/pi-intercom/lifecycle.ts +117 -14
  161. package/src/pi-intercom/outbound.ts +7 -16
  162. package/src/pi-intercom/reply-tracker.ts +8 -6
  163. package/src/pi-intercom/reply-wait.ts +3 -2
  164. package/src/pi-intercom/runtime-types.ts +0 -1
  165. package/src/pi-intercom/tool-registration.ts +4 -1
  166. package/src/pi-intercom/tool-results.ts +10 -9
  167. package/src/pi-intercom/tool-schemas.ts +12 -44
  168. package/src/pi-intercom/types.ts +115 -4
  169. package/src/pi-intercom/validation.ts +4 -0
  170. package/src/shared/bounded-json.ts +162 -0
  171. package/src/shared/compact-cards.ts +118 -0
  172. package/src/shared/journal-frames.ts +3 -19
  173. package/src/shared/json-projection.ts +267 -147
  174. package/src/shared/native-journal.ts +157 -75
  175. package/src/shared/parking.ts +22 -0
  176. package/src/shared/post-exit-stdio-guard.ts +165 -7
  177. package/src/shared/tui-action.ts +39 -0
  178. package/tsconfig.client.json +8 -0
  179. package/tsconfig.json +1 -0
  180. package/src/shared/stream-json.d.ts +0 -10
@@ -0,0 +1,464 @@
1
+ #!/usr/bin/env node
2
+ import assert from "node:assert/strict";
3
+ import { spawnSync } from "node:child_process";
4
+ import { createHash } from "node:crypto";
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import { parseArgs, isDeepStrictEqual } from "node:util";
8
+
9
+ /** @typedef {Readonly<{version: 1, ownerUid: number, source: string, archive: string, sourceParent: unknown, archiveParent: unknown, container: unknown, directory: unknown, members: unknown}>} ArchiveRecord */
10
+
11
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
12
+ function isRecord(value) {
13
+ return typeof value === "object" && value !== null && !Array.isArray(value);
14
+ }
15
+
16
+ const generation = /^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}\.sqlite$/;
17
+ const database =
18
+ /^(?:[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}\.sqlite(?:\.ingest\.sqlite)?|run-timing\.sqlite)$/;
19
+ const manifest = /^[a-f0-9]{64}\.json$/;
20
+ const flags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW;
21
+
22
+ /**
23
+ * @param {unknown} value
24
+ * @returns {string}
25
+ */
26
+ function absolute(value) {
27
+ assert.ok(typeof value === "string" && path.isAbsolute(value), "E_ABSOLUTE_PATH");
28
+ assert.ok(path.resolve(value) === value, "E_CANONICAL_PATH");
29
+ return value;
30
+ }
31
+ /**
32
+ * @param {import("node:fs").BigIntStats} stat
33
+ */
34
+ function identity(stat) {
35
+ return {
36
+ dev: String(stat.dev),
37
+ ino: String(stat.ino),
38
+ uid: String(stat.uid),
39
+ gid: String(stat.gid),
40
+ mode: String(stat.mode),
41
+ };
42
+ }
43
+ /**
44
+ * @param {import("node:fs").BigIntStats} stat
45
+ */
46
+ function stamp(stat) {
47
+ return {
48
+ ...identity(stat),
49
+ size: String(stat.size),
50
+ nlink: String(stat.nlink),
51
+ mtimeNs: String(stat.mtimeNs),
52
+ ctimeNs: String(stat.ctimeNs),
53
+ };
54
+ }
55
+ /**
56
+ * @param {import("node:fs").BigIntStats} stat
57
+ * @param {number} uid
58
+ */
59
+ function owned(stat, uid) {
60
+ assert.ok(stat.uid === BigInt(uid), "E_FOREIGN_OWNER");
61
+ assert.ok((stat.mode & 0o022n) === 0n, "E_UNSAFE_MODE");
62
+ }
63
+ /**
64
+ * @param {string} file
65
+ * @param {number} uid
66
+ */
67
+ function trustedChain(file, uid) {
68
+ let cursor = path.parse(file).root;
69
+ const root = fs.lstatSync(cursor, { bigint: true });
70
+ assert.ok(root.uid === 0n, "E_ANCESTOR_OWNER");
71
+ assert.ok((root.mode & 0o022n) === 0n, "E_ANCESTOR_WRITABLE");
72
+ for (const component of path.relative(cursor, file).split(path.sep)) {
73
+ cursor = path.join(cursor, component);
74
+ const stat = fs.lstatSync(cursor, { bigint: true });
75
+ assert.ok(stat.isDirectory() && !stat.isSymbolicLink(), "E_PATH_LINK");
76
+ assert.ok(stat.uid === 0n || stat.uid === BigInt(uid), "E_ANCESTOR_OWNER");
77
+ if ((stat.mode & 0o022n) !== 0n) {
78
+ assert.ok(stat.uid === 0n && (stat.mode & 0o1000n) !== 0n, "E_ANCESTOR_WRITABLE");
79
+ }
80
+ }
81
+ }
82
+ /**
83
+ * @param {string} file
84
+ * @param {number} uid
85
+ * @param {boolean} [privateOnly]
86
+ */
87
+ function directory(file, uid, privateOnly = false) {
88
+ absolute(file);
89
+ trustedChain(file, uid);
90
+ assert.ok(fs.realpathSync(file) === file, "E_PATH_LINK");
91
+ const stat = fs.lstatSync(file, { bigint: true });
92
+ assert.ok(stat.isDirectory(), "E_DIRECTORY");
93
+ owned(stat, uid);
94
+ if (privateOnly) {
95
+ assert.ok((stat.mode & 0o077n) === 0n, "E_PRIVATE_DIRECTORY");
96
+ }
97
+ return stat;
98
+ }
99
+ /**
100
+ * @param {string} file
101
+ */
102
+ function syncDirectory(file) {
103
+ const fd = fs.openSync(file, flags);
104
+ try {
105
+ fs.fsyncSync(fd);
106
+ } finally {
107
+ fs.closeSync(fd);
108
+ }
109
+ }
110
+ /**
111
+ * @param {string} file
112
+ */
113
+ function exists(file) {
114
+ return fs.lstatSync(file, { throwIfNoEntry: false }) !== undefined;
115
+ }
116
+ /**
117
+ * @param {string} file
118
+ * @param {string} root
119
+ */
120
+ function outside(file, root) {
121
+ assert.ok(file !== root && !file.startsWith(`${root}${path.sep}`), "E_OVERLAPPING_PATHS");
122
+ }
123
+ /**
124
+ * @param {string} directoryPath
125
+ */
126
+ function closed(directoryPath) {
127
+ const executable = process.platform === "darwin" ? "/usr/sbin/lsof" : "/usr/bin/lsof";
128
+ const result = spawnSync(executable, ["-nPl", "-F", "p", "+d", directoryPath], {
129
+ encoding: "utf8",
130
+ timeout: 30_000,
131
+ maxBuffer: 2 * 1024 * 1024,
132
+ });
133
+ assert.ok(result.error === undefined, "E_LSOF_REQUIRED");
134
+ assert.ok(result.signal === null, "E_LSOF_INTERRUPTED");
135
+ assert.ok(result.stderr === "", "E_LSOF_INCOMPLETE");
136
+ assert.ok(result.stdout === "", "E_OPEN_HANDLES");
137
+ assert.ok(result.status === 1, "E_LSOF_RESULT");
138
+ }
139
+ /**
140
+ * @param {string} directoryPath
141
+ * @param {string} name
142
+ * @param {number} uid
143
+ */
144
+ function member(directoryPath, name, uid) {
145
+ const base = name.replace(/-(?:wal|shm)$/, "");
146
+ assert.ok(database.test(base) || manifest.test(name), "E_UNKNOWN_MEMBER");
147
+ const file = path.join(directoryPath, name);
148
+ const before = fs.lstatSync(file, { bigint: true });
149
+ assert.ok(before.isFile() && before.nlink === 1n, "E_MEMBER_TYPE");
150
+ owned(before, uid);
151
+ const fd = fs.openSync(file, flags);
152
+ try {
153
+ assert.ok(
154
+ isDeepStrictEqual(stamp(fs.fstatSync(fd, { bigint: true })), stamp(before)),
155
+ "E_MEMBER_IDENTITY",
156
+ );
157
+ const sha256 = createHash("sha256");
158
+ const buffer = Buffer.alloc(1024 * 1024);
159
+ let length;
160
+ while ((length = fs.readSync(fd, buffer, 0, buffer.length, null)) !== 0) {
161
+ sha256.update(buffer.subarray(0, length));
162
+ }
163
+ assert.ok(
164
+ isDeepStrictEqual(stamp(fs.fstatSync(fd, { bigint: true })), stamp(before)),
165
+ "E_MEMBER_CHANGED",
166
+ );
167
+ assert.ok(
168
+ isDeepStrictEqual(stamp(fs.lstatSync(file, { bigint: true })), stamp(before)),
169
+ "E_MEMBER_PATH_CHANGED",
170
+ );
171
+ return { name, ...stamp(before), sha256: sha256.digest("hex") };
172
+ } finally {
173
+ fs.closeSync(fd);
174
+ }
175
+ }
176
+ /**
177
+ * @param {string} directoryPath
178
+ * @param {number} uid
179
+ */
180
+ function inventory(directoryPath, uid) {
181
+ directory(directoryPath, uid);
182
+ const names = fs.readdirSync(directoryPath).toSorted();
183
+ assert.ok(names.length > 0, "E_EMPTY_SET");
184
+ const members = names.map((name) => member(directoryPath, name, uid));
185
+ for (const name of names) {
186
+ const base = name.replace(/-(?:wal|shm)$/, "");
187
+ assert.ok(names.includes(base), "E_ORPHAN_COMPANION");
188
+ assert.ok(names.includes(`${base}-wal`) === names.includes(`${base}-shm`), "E_INCOMPLETE_WAL");
189
+ if (base.endsWith(".ingest.sqlite")) {
190
+ assert.ok(names.includes(base.replace(/\.ingest\.sqlite$/, "")), "E_ORPHAN_INGEST");
191
+ }
192
+ if (manifest.test(name)) {
193
+ const selected = readManifest(path.join(directoryPath, name));
194
+ assert.ok(names.includes(selected), "E_MISSING_GENERATION");
195
+ }
196
+ }
197
+ assert.ok(
198
+ isDeepStrictEqual(fs.readdirSync(directoryPath).toSorted(), names),
199
+ "E_MEMBERSHIP_CHANGED",
200
+ );
201
+ return members;
202
+ }
203
+ /**
204
+ * @param {string} file
205
+ */
206
+ function readManifest(file) {
207
+ const fd = fs.openSync(file, flags);
208
+ try {
209
+ assert.ok(fs.fstatSync(fd).size <= 65_536, "E_MANIFEST_SIZE");
210
+ /** @type {unknown} */
211
+ const parsed = JSON.parse(fs.readFileSync(fd, "utf8"));
212
+ assert.ok(isRecord(parsed), "E_MANIFEST");
213
+ const selected = parsed.file;
214
+ assert.ok(typeof selected === "string" && generation.test(selected), "E_MANIFEST");
215
+ return selected;
216
+ } finally {
217
+ fs.closeSync(fd);
218
+ }
219
+ }
220
+ /**
221
+ * @param {string} file
222
+ * @param {number} uid
223
+ * @returns {ArchiveRecord}
224
+ */
225
+ function recordAt(file, uid) {
226
+ directory(path.dirname(absolute(file)), uid, true);
227
+ const fd = fs.openSync(file, flags);
228
+ try {
229
+ const stat = fs.fstatSync(fd, { bigint: true });
230
+ owned(stat, uid);
231
+ assert.ok(stat.isFile() && stat.nlink === 1n && stat.size <= 16_777_216n, "E_RECORD_TYPE");
232
+ assert.ok((stat.mode & 0o077n) === 0n, "E_PRIVATE_RECORD");
233
+ /** @type {unknown} */
234
+ const parsed = JSON.parse(fs.readFileSync(fd, "utf8"));
235
+ assert.ok(isRecord(parsed), "E_RECORD_TYPE");
236
+ assert.ok(parsed.version === 1, "E_RECORD_VERSION");
237
+ assert.ok(parsed.ownerUid === uid, "E_RECORD_OWNER");
238
+ /** @type {ArchiveRecord} */
239
+ const record = {
240
+ version: 1,
241
+ ownerUid: uid,
242
+ source: absolute(parsed.source),
243
+ archive: absolute(parsed.archive),
244
+ sourceParent: parsed.sourceParent,
245
+ archiveParent: parsed.archiveParent,
246
+ container: parsed.container,
247
+ directory: parsed.directory,
248
+ members: parsed.members,
249
+ };
250
+ assert.ok(
251
+ isDeepStrictEqual(stamp(fs.fstatSync(fd, { bigint: true })), stamp(stat)),
252
+ "E_RECORD_CHANGED",
253
+ );
254
+ assert.ok(
255
+ isDeepStrictEqual(stamp(fs.lstatSync(file, { bigint: true })), stamp(stat)),
256
+ "E_RECORD_PATH_CHANGED",
257
+ );
258
+ assert.ok(path.basename(record.source) === "history-index", "E_SOURCE_NAME");
259
+ outside(record.archive, record.source);
260
+ outside(file, record.source);
261
+ outside(file, record.archive);
262
+ return record;
263
+ } finally {
264
+ fs.closeSync(fd);
265
+ }
266
+ }
267
+ /**
268
+ * @param {ArchiveRecord} record
269
+ * @param {number} uid
270
+ */
271
+ function parents(record, uid) {
272
+ assert.ok(
273
+ isDeepStrictEqual(identity(directory(path.dirname(record.source), uid)), record.sourceParent),
274
+ "E_SOURCE_PARENT_CHANGED",
275
+ );
276
+ assert.ok(
277
+ isDeepStrictEqual(
278
+ identity(directory(path.dirname(record.archive), uid, true)),
279
+ record.archiveParent,
280
+ ),
281
+ "E_ARCHIVE_PARENT_CHANGED",
282
+ );
283
+ assert.ok(
284
+ isDeepStrictEqual(identity(directory(record.archive, uid, true)), record.container),
285
+ "E_CONTAINER_CHANGED",
286
+ );
287
+ assert.ok(
288
+ directory(path.dirname(record.source), uid).dev === directory(record.archive, uid, true).dev,
289
+ "E_CROSS_DEVICE",
290
+ );
291
+ }
292
+ /**
293
+ * @param {ArchiveRecord} record
294
+ * @param {number} uid
295
+ */
296
+ function inspect(record, uid) {
297
+ parents(record, uid);
298
+ const destination = path.join(record.archive, "history-index");
299
+ const sourceExists = exists(record.source);
300
+ const destinationExists = exists(destination);
301
+ assert.ok(sourceExists !== destinationExists, "E_AMBIGUOUS_PATHS");
302
+ assert.ok(
303
+ isDeepStrictEqual(
304
+ fs.readdirSync(record.archive).toSorted(),
305
+ destinationExists ? ["history-index"] : [],
306
+ ),
307
+ "E_UNEXPECTED_ARCHIVE_CONTAINER_CONTENTS",
308
+ );
309
+ const location = sourceExists ? record.source : destination;
310
+ assert.ok(
311
+ isDeepStrictEqual(identity(directory(location, uid)), record.directory),
312
+ "E_SOURCE_CHANGED",
313
+ );
314
+ closed(location);
315
+ assert.ok(isDeepStrictEqual(inventory(location, uid), record.members), "E_INVENTORY_CHANGED");
316
+ closed(location);
317
+ parents(record, uid);
318
+ return sourceExists ? "prepared" : "archived";
319
+ }
320
+ /**
321
+ * @param {string} file
322
+ * @param {ArchiveRecord} record
323
+ * @param {number} uid
324
+ * @param {number} gid
325
+ */
326
+ function saveRecord(file, record, uid, gid) {
327
+ const fd = fs.openSync(
328
+ file,
329
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW,
330
+ 0o600,
331
+ );
332
+ try {
333
+ fs.fchownSync(fd, uid, gid);
334
+ fs.writeFileSync(fd, `${JSON.stringify(record, null, 2)}\n`);
335
+ fs.fsyncSync(fd);
336
+ } finally {
337
+ fs.closeSync(fd);
338
+ }
339
+ syncDirectory(path.dirname(file));
340
+ }
341
+ /**
342
+ * @param {Readonly<{source?: string, archive?: string, record?: string}>} values
343
+ * @param {number} uid
344
+ */
345
+ function prepare(values, uid) {
346
+ const source = absolute(values.source);
347
+ const archive = absolute(values.archive);
348
+ const file = absolute(values.record);
349
+ assert.ok(path.basename(source) === "history-index", "E_SOURCE_NAME");
350
+ outside(archive, source);
351
+ outside(source, archive);
352
+ outside(file, source);
353
+ outside(file, archive);
354
+ const recordParent = directory(path.dirname(file), uid, true);
355
+ if (exists(file)) {
356
+ const record = recordAt(file, uid);
357
+ assert.ok(record.source === source, "E_RECORD_SOURCE");
358
+ assert.ok(record.archive === archive, "E_RECORD_ARCHIVE");
359
+ return inspect(record, uid);
360
+ }
361
+ const sourceParent = directory(path.dirname(source), uid);
362
+ const archiveParent = directory(path.dirname(archive), uid, true);
363
+ const sourceStat = directory(source, uid);
364
+ for (const parent of [archiveParent, recordParent]) {
365
+ assert.ok(
366
+ sourceStat.dev !== parent.dev || sourceStat.ino !== parent.ino,
367
+ "E_OVERLAPPING_PATHS",
368
+ );
369
+ }
370
+ assert.ok(sourceStat.dev === archiveParent.dev, "E_CROSS_DEVICE");
371
+ assert.ok(!exists(archive), "E_UNRECORDED_CONTAINER");
372
+ const members = inventory(source, uid);
373
+ closed(source);
374
+ fs.mkdirSync(archive, { mode: 0o700 });
375
+ fs.chownSync(archive, uid, Number(sourceStat.gid));
376
+ /** @type {ArchiveRecord} */
377
+ const record = {
378
+ version: 1,
379
+ ownerUid: uid,
380
+ source,
381
+ archive,
382
+ sourceParent: identity(sourceParent),
383
+ archiveParent: identity(archiveParent),
384
+ container: identity(directory(archive, uid, true)),
385
+ directory: identity(sourceStat),
386
+ members,
387
+ };
388
+ saveRecord(file, record, uid, Number(sourceStat.gid));
389
+ syncDirectory(path.dirname(archive));
390
+ return inspect(record, uid);
391
+ }
392
+ function main() {
393
+ const { values, positionals } = parseArgs({
394
+ options: {
395
+ source: { type: "string" },
396
+ archive: { type: "string" },
397
+ record: { type: "string" },
398
+ "owner-uid": { type: "string" },
399
+ quiesced: { type: "boolean" },
400
+ help: { type: "boolean", short: "h" },
401
+ },
402
+ allowPositionals: true,
403
+ });
404
+ if (values.help === true) {
405
+ console.log(
406
+ "Usage: node scripts/archive-retired-history.mjs prepare --source /canonical/agent/history-index --archive /private/archives/unique-container --record /private/records/unique.json --owner-uid UID --quiesced\n node scripts/archive-retired-history.mjs inspect --record /private/records/unique.json --owner-uid UID\n node scripts/archive-retired-history.mjs archive --record /private/records/unique.json --owner-uid UID --quiesced\n\nOffline-only opt-in maintenance on Linux/macOS. Run with administrative lsof visibility. --quiesced acknowledges owner-approved stopped/disabled launchers, services, readers and writers; it is NOT an exclusivity check. Read docs/retired-history-maintenance.md first. No startup/install integration, deletion, SQLite open/recovery, or cross-filesystem copy. An interrupted or ambiguous attempt retains its record and both paths for inspection. Archiving reclaims no file allocations.",
407
+ );
408
+ return;
409
+ }
410
+ assert.ok(positionals.length === 1, "E_ACTION");
411
+ const action = positionals[0];
412
+ assert.ok(["prepare", "inspect", "archive"].includes(action), "E_ACTION");
413
+ assert.ok(["linux", "darwin"].includes(process.platform), "E_PLATFORM");
414
+ assert.ok(process.geteuid !== undefined, "E_PLATFORM");
415
+ assert.ok(process.geteuid() === 0, "E_PRIVILEGE");
416
+ if (process.platform === "linux") {
417
+ const capability = /^CapEff:\s*([a-f0-9]+)$/im.exec(
418
+ fs.readFileSync("/proc/self/status", "utf8"),
419
+ );
420
+ assert.ok(capability !== null, "E_PROCESS_VISIBILITY");
421
+ assert.ok((BigInt(`0x${capability[1]}`) & (1n << 19n)) !== 0n, "E_PROCESS_VISIBILITY");
422
+ }
423
+ assert.match(values["owner-uid"] ?? "", /^(?:0|[1-9][0-9]*)$/, "E_OWNER_UID");
424
+ const uid = Number(values["owner-uid"]);
425
+ assert.ok(Number.isSafeInteger(uid) && uid <= 4_294_967_294, "E_OWNER_UID");
426
+ if (action !== "inspect") {
427
+ assert.ok(values.quiesced === true, "E_QUIESCENCE_ACK");
428
+ }
429
+ const file = absolute(values.record);
430
+ if (action === "prepare") {
431
+ console.log(
432
+ `${prepare(values, uid)}: inventory recorded; nothing deleted and no file allocations reclaimed.`,
433
+ );
434
+ return;
435
+ }
436
+ assert.ok((values.source ?? values.archive) === undefined, "E_RECORD_ONLY");
437
+ const record = recordAt(file, uid);
438
+ let state = inspect(record, uid);
439
+ if (action === "archive" && state === "prepared") {
440
+ assert.ok(!exists(path.join(record.archive, "history-index")), "E_DESTINATION_APPEARED");
441
+ fs.renameSync(record.source, path.join(record.archive, "history-index"));
442
+ syncDirectory(path.dirname(record.source));
443
+ syncDirectory(record.archive);
444
+ state = inspect(record, uid);
445
+ }
446
+ console.log(
447
+ `${state}: verified physical state; nothing deleted and no file allocations reclaimed.`,
448
+ );
449
+ }
450
+ try {
451
+ main();
452
+ } catch (error) {
453
+ let code = "E_INSPECTION_FAILED";
454
+ if (error instanceof assert.AssertionError && !error.generatedMessage) {
455
+ const firstLine = error.message.split("\n")[0];
456
+ if (/^E_[A-Z_]+$/.test(firstLine)) {
457
+ code = firstLine;
458
+ }
459
+ }
460
+ console.error(
461
+ `[retired-history] ${code}\nStop. Preserve the external record and both locations; do not delete, merge or retry blindly. See the maintenance guide. Private paths, contents and inspector output are not printed.`,
462
+ );
463
+ process.exitCode = 1;
464
+ }
package/scripts/build.mjs CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Purpose: Produce the compiled runtime files that the Pi extension manifest loads.
4
- * Responsibilities: Run TypeScript emit into a staging directory, then atomically swap it
5
- * into dist/ so a failed TypeScript emit never destroys a previously working dist.
4
+ * Responsibilities: Run TypeScript emit into a staging directory, move the last-good dist/ aside,
5
+ * and rename the complete staging tree into place. A failed emit never touches dist/; a failed
6
+ * publication renames the last-good tree back. Replacement is two renames, not an atomic exchange:
7
+ * a concurrent reader can briefly find no dist/, but never a partial tree.
6
8
  * Usage: `npm run build`; also invoked by scripts/prepare.mjs during install lifecycles, which may pass
7
- * the path of a throwaway compiler's `bin/tsc` as the first argument.
8
- * Invariants/Assumptions: `node_modules` (or that argument) provides `typescript`; deleting `dist/` is safe generated output.
9
+ * the path of a throwaway compiler's `bin/tsc` as the first argument. Android uses native tsgo.
10
+ * Invariants/Assumptions: deleting `dist/` is safe generated output.
9
11
  */
10
12
 
11
13
  import { execFile as execFileCallback } from "node:child_process";
@@ -16,17 +18,16 @@ import { join } from "node:path";
16
18
  import process from "node:process";
17
19
  import { setTimeout as delay } from "node:timers/promises";
18
20
  import { promisify } from "node:util";
21
+ import { compilerInvocation } from "./typescript.mjs";
19
22
 
20
23
  const execFile = promisify(execFileCallback);
21
24
  const RM_OPTIONS = { force: true, maxRetries: 5, recursive: true, retryDelay: 100 };
22
25
  const RENAME_RETRY_LIMIT = 50;
23
26
  const RENAME_RETRY_MS = 50;
24
- // Use the current Node binary without a shell, including install paths with spaces.
25
- const tscPath = process.argv[2] ?? join(process.cwd(), "node_modules", "typescript", "bin", "tsc");
26
27
 
27
28
  if (process.argv.includes("--help") || process.argv.includes("-h")) {
28
29
  console.log(
29
- "Usage: node scripts/build.mjs [compiler-path]\n\nCompile into PID-owned staging, stamp the package version/content hash, then publish dist.\nA failed compiler preserves existing dist. Concurrent publishers retain the winning tree.\nExample: npm run build\nExit codes: 0 published or concurrent winner retained; 1 compile/publish failed.",
30
+ "Usage: node scripts/build.mjs [compiler-path]\n\nCompile into PID-owned staging, stamp the package version/content hash, then publish dist.\nA failed compiler preserves existing dist; a failed publication restores it. Concurrent publishers retain a complete winning tree.\nExample: npm run build\nExit codes: 0 published or concurrent winner retained; 1 compile/publish failed.",
30
31
  );
31
32
  process.exit(0);
32
33
  }
@@ -62,7 +63,7 @@ async function reapStrandedStaging(cwd) {
62
63
  // Signal 0 proves only that the owner was gone at probe time. Pid reuse
63
64
  // between this probe and removal is inherent to pid-based reaping.
64
65
  for (const entry of await readdir(cwd, { withFileTypes: true })) {
65
- const match = /^dist\.staging\.(\d+)$/.exec(entry.name);
66
+ const match = /^dist\.staging\.(\d+)(?:\.previous)?$/.exec(entry.name);
66
67
  if (!match || !entry.isDirectory()) {
67
68
  continue;
68
69
  }
@@ -76,19 +77,24 @@ async function reapStrandedStaging(cwd) {
76
77
  }
77
78
  }
78
79
 
79
- async function compileToStaging(cwd, stagingDir) {
80
+ async function compileProject(cwd, stagingDir, compiler, project) {
81
+ const { stderr, stdout } = await execFile(
82
+ compiler.command,
83
+ [...compiler.args, "-p", project, "--outDir", stagingDir],
84
+ { cwd, maxBuffer: 10 * 1024 * 1024 },
85
+ );
86
+ if (stdout) {
87
+ process.stdout.write(stdout);
88
+ }
89
+ if (stderr) {
90
+ process.stderr.write(stderr);
91
+ }
92
+ }
93
+
94
+ async function compileToStaging(cwd, stagingDir, compiler) {
80
95
  try {
81
- const { stderr, stdout } = await execFile(
82
- process.execPath,
83
- [tscPath, "-p", "tsconfig.build.json", "--outDir", stagingDir],
84
- { cwd, maxBuffer: 10 * 1024 * 1024 },
85
- );
86
- if (stdout) {
87
- process.stdout.write(stdout);
88
- }
89
- if (stderr) {
90
- process.stderr.write(stderr);
91
- }
96
+ await compileProject(cwd, stagingDir, compiler, "tsconfig.build.json");
97
+ await compileProject(cwd, stagingDir, compiler, "tsconfig.client.json");
92
98
  await stampBuild(cwd, stagingDir);
93
99
  } catch (error) {
94
100
  if (error?.stdout) {
@@ -142,17 +148,32 @@ async function tryPublish(stagingDir, distDir, retry) {
142
148
  }
143
149
 
144
150
  async function publishStaging(stagingDir, distDir) {
151
+ const previousDir = `${stagingDir}.previous`;
145
152
  try {
146
- // A failed dist removal must fail loudly; keep it outside the retry loop.
147
- // Remove dist exactly once so retries never delete a concurrent winner.
148
- await rm(distDir, RM_OPTIONS);
153
+ // Retire dist exactly once, outside the retry loop, so retries never retire a concurrent winner.
154
+ await rename(distDir, previousDir).catch((error) => {
155
+ if (error?.code !== "ENOENT") {
156
+ throw error;
157
+ }
158
+ });
149
159
  for (let retry = 0; ; retry++) {
150
160
  // Each retry must observe publication before another rename can race it.
151
161
  // oxlint-disable-next-line no-await-in-loop
152
162
  if (await tryPublish(stagingDir, distDir, retry)) {
153
- return;
163
+ break;
154
164
  }
155
165
  }
166
+ await discardStaging(previousDir);
167
+ } catch (error) {
168
+ // ENOENT: nothing was retired. Otherwise a concurrent winner may already own dist.
169
+ await rename(previousDir, distDir).catch((restoreError) => {
170
+ if (restoreError?.code !== "ENOENT") {
171
+ console.warn(
172
+ `could not restore ${previousDir} to dist: ${restoreError?.message ?? restoreError}`,
173
+ );
174
+ }
175
+ });
176
+ throw error;
156
177
  } finally {
157
178
  // Safe after success too: rename moved stagingDir, so force makes this a no-op.
158
179
  await discardStaging(stagingDir);
@@ -161,14 +182,13 @@ async function publishStaging(stagingDir, distDir) {
161
182
 
162
183
  async function main() {
163
184
  const cwd = process.cwd();
164
- if (!existsSync(tscPath)) {
165
- throw new Error(`typescript is not installed at ${tscPath}; run npm install first.`);
166
- }
185
+ const compiler = compilerInvocation(process.argv[2]);
167
186
  await reapStrandedStaging(cwd);
168
187
  // Pid-scoped staging isolates concurrent emits; only a complete tree publishes.
169
188
  const stagingDir = join(cwd, `dist.staging.${process.pid}`);
170
189
  await rm(stagingDir, RM_OPTIONS);
171
- await compileToStaging(cwd, stagingDir);
190
+ await rm(`${stagingDir}.previous`, RM_OPTIONS);
191
+ await compileToStaging(cwd, stagingDir, compiler);
172
192
  await publishStaging(stagingDir, join(cwd, "dist"));
173
193
  }
174
194
 
@@ -4,7 +4,7 @@ import { extname, join } from "node:path";
4
4
 
5
5
  const roots = ["src", "scripts", "generated"];
6
6
  const extensions = new Set([".ts", ".js", ".mjs", ".cjs", ".json", ".sh"]);
7
- const ceilings = { sourceLines: 16000, productionLines: 20000, productionBytes: 1048576 };
7
+ const ceilings = { productionBytes: 1048576 };
8
8
 
9
9
  /**
10
10
  * @param {string} directory