@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
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
- import { scanJournal } from "./journal-frames.js";
2
+ import { JournalFrames } from "./journal-frames.js";
3
3
  import { nativeProjection } from "./journal-projections.js";
4
- import { isRecord, recordAt } from "./unknown.js";
4
+ import { isRecord } from "./unknown.js";
5
5
  function preview(record, parent, legacy) {
6
6
  const entry = record.value;
7
7
  if (typeof entry.type !== "string") {
@@ -18,45 +18,40 @@ function preview(record, parent, legacy) {
18
18
  };
19
19
  return { value, start: record.start, end: record.end };
20
20
  }
21
- function configurationModel(value) {
22
- const message = recordAt(value, "message");
23
- const provider = value.type === "model_change" ? value.provider : message?.provider;
24
- const model = value.type === "model_change" ? value.modelId : message?.model;
25
- return typeof provider === "string" && typeof model === "string"
26
- ? `${provider}/${model}`
27
- : undefined;
28
- }
29
21
  export class NativeJournal {
30
22
  records = [];
31
23
  byId = new Map();
32
24
  stamp;
33
25
  end;
34
26
  file;
35
- constructor(file, policy = "inspect", requireNewline = policy === "live") {
27
+ identity;
28
+ offset = 0;
29
+ frames;
30
+ constructor(file, policy = "inspect", requireNewline = policy === "live", options = {}) {
36
31
  this.file = file;
37
- const fd = fs.openSync(file, "r");
32
+ let parent = null;
33
+ this.frames = new JournalFrames(options.select ?? nativeProjection, (raw) => {
34
+ const record = preview(raw, parent, this.records[0]?.value.version === 1);
35
+ if (!record) {
36
+ if (policy !== "inspect") {
37
+ throw new SyntaxError(`Native record at byte ${raw.start} requires a type`);
38
+ }
39
+ return;
40
+ }
41
+ if (record.value.type !== "session") {
42
+ parent = record.value.id;
43
+ }
44
+ this.records.push(record);
45
+ this.byId.set(record.value.id, record);
46
+ }, { policy, requireNewline });
47
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (policy === "live" ? fs.constants.O_NOFOLLOW : 0));
38
48
  try {
39
49
  const stat = fs.fstatSync(fd, { bigint: true });
40
50
  this.stamp = journalStamp(stat);
51
+ this.identity = `${stat.dev}:${stat.ino}`;
41
52
  this.end = Number(stat.size);
42
- let parent = null;
43
- scanJournal(fd, nativeProjection, (raw) => {
44
- const record = preview(raw, parent, this.records[0]?.value.version === 1);
45
- if (!record) {
46
- if (policy !== "inspect") {
47
- throw new SyntaxError(`Native record at byte ${raw.start} requires a type`);
48
- }
49
- return;
50
- }
51
- if (record.value.type !== "session") {
52
- parent = record.value.id;
53
- }
54
- this.records.push(record);
55
- this.byId.set(record.value.id, record);
56
- }, { policy, requireNewline, end: this.end });
57
- if (journalStamp(fs.fstatSync(fd, { bigint: true })) !== this.stamp) {
58
- throw new Error("Journal changed while indexing; refresh history.");
59
- }
53
+ this.index(fd, Math.min(this.end, options.readBytes ?? Infinity));
54
+ this.checkInitial(stat, fs.fstatSync(fd, { bigint: true }), policy);
60
55
  }
61
56
  finally {
62
57
  fs.closeSync(fd);
@@ -65,21 +60,101 @@ export class NativeJournal {
65
60
  throw new Error("Not a readable native Pi session.");
66
61
  }
67
62
  }
68
- branch(leaf, endedAt) {
63
+ checkInitial(before, after, policy) {
64
+ if (journalStamp(after) !== this.stamp &&
65
+ (policy !== "live" ||
66
+ after.size < before.size ||
67
+ `${after.dev}:${after.ino}` !== this.identity)) {
68
+ throw new Error("Journal changed while indexing; refresh history.");
69
+ }
70
+ }
71
+ index(fd, end) {
72
+ const buffer = Buffer.allocUnsafe(65536);
73
+ while (this.offset < end) {
74
+ const count = fs.readSync(fd, buffer, 0, Math.min(buffer.length, end - this.offset), this.offset);
75
+ if (count === 0) {
76
+ throw new Error("Journal truncated while reading");
77
+ }
78
+ this.frames.write(buffer.subarray(0, count));
79
+ this.offset += count;
80
+ }
81
+ this.frames.finish();
82
+ }
83
+ get indexed() {
84
+ return this.offset === this.end;
85
+ }
86
+ /** Incremental, LF-published indexing. Append work is budgeted independently of paint. */
87
+ refresh(bytes = 256 * 1024) {
88
+ const fd = fs.openSync(this.file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
89
+ try {
90
+ const stat = fs.fstatSync(fd, { bigint: true });
91
+ if (`${stat.dev}:${stat.ino}` !== this.identity || Number(stat.size) < this.offset) {
92
+ throw new Error("Native journal was replaced or truncated; reopen this conversation.");
93
+ }
94
+ if (journalStamp(stat) === this.stamp && this.indexed) {
95
+ return false;
96
+ }
97
+ const previous = this.records.length;
98
+ this.end = Number(stat.size);
99
+ this.index(fd, Math.min(this.end, this.offset + bytes));
100
+ this.stamp = journalStamp(stat);
101
+ return previous !== this.records.length;
102
+ }
103
+ finally {
104
+ fs.closeSync(fd);
105
+ }
106
+ }
107
+ /** Project one immutable, published record, including bounded slices of very large strings. */
108
+ async project(record, select, stringChunk, signal) {
109
+ const fd = fs.openSync(this.file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
110
+ let stream;
111
+ try {
112
+ const stat = fs.fstatSync(fd, { bigint: true });
113
+ if (`${stat.dev}:${stat.ino}` !== this.identity || Number(stat.size) < record.end) {
114
+ throw new Error("Native journal was replaced or truncated.");
115
+ }
116
+ let value;
117
+ const frames = new JournalFrames(select, (entry) => {
118
+ value = entry.value;
119
+ }, { policy: "live", start: record.start, stringChunk });
120
+ stream = fs.createReadStream(this.file, {
121
+ fd,
122
+ start: record.start,
123
+ end: record.end - 1,
124
+ highWaterMark: 65536,
125
+ signal,
126
+ });
127
+ for await (const chunk of stream) {
128
+ if (!Buffer.isBuffer(chunk)) {
129
+ throw new TypeError("Native journal reads require bytes.");
130
+ }
131
+ frames.write(chunk);
132
+ }
133
+ frames.finish();
134
+ if (!value) {
135
+ throw new Error("Native entry is not published.");
136
+ }
137
+ return value;
138
+ }
139
+ finally {
140
+ if (stream) {
141
+ stream.destroy();
142
+ }
143
+ else {
144
+ fs.closeSync(fd);
145
+ }
146
+ }
147
+ }
148
+ branch(leaf) {
69
149
  if (leaf === null) {
70
150
  return [];
71
151
  }
72
- const eligible = endedAt === undefined
73
- ? this.records
74
- : this.records.filter((record) => record.value.type === "session" ||
75
- Date.parse(record.value.timestamp ?? "") <= endedAt);
76
152
  let current = leaf !== undefined && leaf !== ""
77
153
  ? this.byId.get(leaf)
78
- : eligible.findLast((record) => record.value.type !== "session");
154
+ : this.records.findLast((record) => record.value.type !== "session");
79
155
  const path = [];
80
156
  const seen = new Set();
81
- const allowed = new Set(eligible.map((record) => record.value.id));
82
- while (current && current.value.type !== "session" && allowed.has(current.value.id)) {
157
+ while (current && current.value.type !== "session") {
83
158
  if (seen.has(current.value.id)) {
84
159
  throw new Error("Cyclic journal ancestry");
85
160
  }
@@ -116,24 +191,16 @@ export class NativeJournal {
116
191
  fs.closeSync(fd);
117
192
  }
118
193
  }
119
- configuration(endedAt, leaf) {
120
- const result = {};
121
- let model;
122
- for (const { value } of this.branch(leaf, endedAt)) {
123
- if (value.type === "thinking_level_change" && typeof value.thinkingLevel === "string") {
124
- result.thinking = value.thinkingLevel;
125
- }
126
- if (value.type === "model_change" ||
127
- (value.type === "message" && recordAt(value, "message")?.role === "assistant")) {
128
- model = value;
129
- }
130
- }
131
- if (model) {
132
- result.model = configurationModel(model);
133
- result.modelRecordedAt = Date.parse(model.timestamp ?? "");
134
- }
135
- return result;
136
- }
194
+ }
195
+ /** Native v3 writers append after attachment; loading older formats can rewrite in place.
196
+ * Outside prefix edits followed by same-inode growth are not an append-only operation.
197
+ */
198
+ export function nativeAppend(before, after, header) {
199
+ return (header?.type === "session" &&
200
+ header.version === 3 &&
201
+ before.dev === after.dev &&
202
+ before.ino === after.ino &&
203
+ after.size > before.size);
137
204
  }
138
205
  export function journalStamp(stat) {
139
206
  return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
@@ -0,0 +1,12 @@
1
+ import { isRecord } from "./unknown.js";
2
+ /** A child actor asks its intercom to fence broker input before parking, or to release it. */
3
+ export const PARK = "pi-subagents:park";
4
+ /** A main intercom wakes or reads an exited parked child through the agent runtime. */
5
+ export const WAKE = "pi-subagents:wake";
6
+ export const PARKED_HISTORY = "pi-subagents:parked-history";
7
+ export function isParkRequest(value) {
8
+ return isRecord(value) && typeof value.park === "boolean" && typeof value.reply === "function";
9
+ }
10
+ export function isHistoryRequest(value) {
11
+ return isRecord(value) && typeof value.peer === "string" && typeof value.reply === "function";
12
+ }
@@ -1,6 +1,27 @@
1
- import { execFileSync } from "node:child_process";
2
- import { readLinuxProcess } from "../pi-intercom/broker/pid.js";
1
+ import { execFile, execFileSync } from "node:child_process";
2
+ import { readFileSync, readlinkSync } from "node:fs";
3
+ import { isLinuxProcessReplaced, readLinuxProcess } from "../pi-intercom/broker/pid.js";
3
4
  import { hasErrorCode } from "./unknown.js";
5
+ const BIRTH_FIELDS = ["lstart=", "pgid="];
6
+ /** Capture only a real, cross-process clock context; unavailable context proves nothing. */
7
+ export function readProcessClock() {
8
+ if (process.platform !== "android") {
9
+ return undefined;
10
+ }
11
+ try {
12
+ const boot = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
13
+ const namespace = readlinkSync("/proc/self/ns/time");
14
+ const at = process.hrtime.bigint();
15
+ return at > 0n &&
16
+ /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/.test(boot) &&
17
+ /^time:\[\d+\]$/.test(namespace)
18
+ ? { boot, namespace, at: at.toString() }
19
+ : undefined;
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ }
4
25
  /** Capture the detached group leader's birth identity; Pi may replace its command-line title. */
5
26
  export function readChildProcessIdentity(pid) {
6
27
  if (!Number.isSafeInteger(pid) || pid <= 0) {
@@ -9,21 +30,113 @@ export function readChildProcessIdentity(pid) {
9
30
  if (process.platform === "linux") {
10
31
  return readLinuxProcess(pid)?.identity;
11
32
  }
33
+ return groupLeader(pid, readPsFields(pid, BIRTH_FIELDS));
34
+ }
35
+ function groupLeader(pid, birth) {
36
+ return Number(birth?.split(" ").at(-1)) === pid ? birth : undefined;
37
+ }
38
+ // Always one PID: Darwin ps given several PIDs scans every process at many times the cost.
39
+ function psArguments(pid, fields) {
40
+ return ["-p", String(pid), ...fields.flatMap((field) => ["-o", field])];
41
+ }
42
+ function psOptions(zone) {
43
+ const env = { ...process.env, LC_ALL: "C", ...(zone === undefined ? {} : { TZ: zone }) };
44
+ return { encoding: "utf8", timeout: 1000, env };
45
+ }
46
+ function readPsFields(pid, fields, zone) {
12
47
  try {
13
- const identity = execFileSync("ps", ["-p", String(pid), "-o", "lstart=", "-o", "pgid="], {
14
- encoding: "utf8",
15
- timeout: 1000,
48
+ return execFileSync("ps", psArguments(pid, fields), {
49
+ ...psOptions(zone),
16
50
  stdio: ["ignore", "pipe", "ignore"],
17
- env: { ...process.env, LC_ALL: "C" },
18
51
  })
19
52
  .trim()
20
53
  .replace(/\s+/g, " ");
21
- return Number(identity.split(" ").at(-1)) === pid ? identity : undefined;
22
54
  }
23
55
  catch {
24
56
  return undefined;
25
57
  }
26
58
  }
59
+ /** Nonblocking `readPsFields`; a failed run likewise observes nothing. */
60
+ function observePsFields(pid, fields, zone) {
61
+ return new Promise((resolve) => {
62
+ execFile("ps", psArguments(pid, fields), psOptions(zone), (error, stdout) => {
63
+ resolve(error ? undefined : stdout.trim().replace(/\s+/g, " "));
64
+ });
65
+ });
66
+ }
67
+ // C-locale ps lstart (whole-second birth, in the reader's zone), then the process group.
68
+ const PS_IDENTITY = /^[A-Z][a-z]{2} ([A-Z][a-z]{2}) (\d{1,2}) (\d{2}):(\d{2}):(\d{2}) (\d{4}) \d+$/;
69
+ const MONTHS = "JanFebMarAprMayJunJulAugSepOctNovDec";
70
+ /** Parses a birth observed with TZ=UTC0. */
71
+ function utcBirth(birth) {
72
+ const [, month = "", day, hours, minutes, seconds, year] = PS_IDENTITY.exec(birth ?? "") ?? [];
73
+ const index = MONTHS.indexOf(month);
74
+ // Unreadable or malformed observations yield NaN, which never proves a later birth.
75
+ return month.length === 3 && index % 3 === 0
76
+ ? Date.UTC(Number(year), index / 3, Number(day), Number(hours), Number(minutes), Number(seconds))
77
+ : Number.NaN;
78
+ }
79
+ function sameClock(left, right) {
80
+ return left.boot === right.boot && left.namespace === right.namespace;
81
+ }
82
+ /** Android's elapsed age bounds birth in the saved monotonic clock, never wall time. */
83
+ function clockBound(clock) {
84
+ const before = readProcessClock();
85
+ return clock !== undefined && before !== undefined && sameClock(clock, before)
86
+ ? { clock, before }
87
+ : undefined;
88
+ }
89
+ /** `elapsed` is observed after the bound's `before` read; the closing clock read follows both. */
90
+ function bornAfter({ clock, before }, elapsed = "") {
91
+ const after = readProcessClock();
92
+ if (after === undefined || !sameClock(before, after) || !/^\d+$/.test(elapsed)) {
93
+ return false;
94
+ }
95
+ // Include displayed-second and native tick uncertainty. BOOTTIME age includes suspend,
96
+ // unlike hrtime's MONOTONIC clock, so suspend only moves this lower bound earlier.
97
+ const birth = BigInt(before.at) - (BigInt(elapsed) + 2n) * 1000000000n;
98
+ return BigInt(after.at) >= BigInt(before.at) && birth > BigInt(clock.at);
99
+ }
100
+ /**
101
+ * True only when a live PID provably names a different process than an owner-published record.
102
+ * Darwin identities are unzoned local times, so they are never compared across zones; only a
103
+ * UTC-observed birth strictly after the record's publication proves replacement. Android uses
104
+ * a conservative elapsed-age bound against its recorded boot-qualified monotonic clock.
105
+ */
106
+ export function isChildProcessReplaced(pid, recorded, published, clock) {
107
+ if (process.platform === "linux") {
108
+ return isLinuxProcessReplaced(pid, recorded);
109
+ }
110
+ if (!PS_IDENTITY.test(recorded)) {
111
+ return false;
112
+ }
113
+ if (process.platform === "android") {
114
+ const bound = clockBound(clock);
115
+ return bound !== undefined && bornAfter(bound, readPsFields(pid, ["etimes="]));
116
+ }
117
+ return published !== undefined && utcBirth(readPsFields(pid, BIRTH_FIELDS, "UTC0")) > published;
118
+ }
119
+ /**
120
+ * Nonblocking `readChildProcessIdentity(pid) !== recorded && isChildProcessReplaced(...)` for
121
+ * routine liveness. Signals still need the fresh synchronous proof.
122
+ */
123
+ export async function observeChildProcessReplaced(pid, recorded, published, clock) {
124
+ if (process.platform === "linux") {
125
+ return readChildProcessIdentity(pid) !== recorded && isLinuxProcessReplaced(pid, recorded);
126
+ }
127
+ if (!PS_IDENTITY.test(recorded) ||
128
+ groupLeader(pid, await observePsFields(pid, BIRTH_FIELDS)) === recorded) {
129
+ return false;
130
+ }
131
+ if (process.platform === "android") {
132
+ const bound = clockBound(clock);
133
+ return bound !== undefined && bornAfter(bound, await observePsFields(pid, ["etimes="]));
134
+ }
135
+ if (published === undefined) {
136
+ return false;
137
+ }
138
+ return utcBirth(await observePsFields(pid, BIRTH_FIELDS, "UTC0")) > published;
139
+ }
27
140
  function trySignalChild(child, signal) {
28
141
  try {
29
142
  return child.kill(signal);
@@ -0,0 +1,29 @@
1
+ import { MouseRegion, TruncatedText, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
2
+ export function actionText(text, action) {
3
+ const component = new TruncatedText(text, 0, 0);
4
+ return new MouseRegion(component, (event) => {
5
+ if ((event.type !== "press" && event.type !== "click") ||
6
+ event.button !== "left" ||
7
+ event.x >= visibleWidth(truncateToWidth(text, event.width))) {
8
+ return;
9
+ }
10
+ if (event.type === "click") {
11
+ action(event.x);
12
+ }
13
+ return { handled: true, render: event.type === "click" };
14
+ });
15
+ }
16
+ /** Each visible hint dispatches the same action as its corresponding keyboard binding. */
17
+ export function actionHints(hints) {
18
+ return actionText(hints.map((hint) => hint.text).join(" · "), (column) => {
19
+ let start = 0;
20
+ for (const hint of hints) {
21
+ const end = start + visibleWidth(hint.text);
22
+ if (column >= start && column < end) {
23
+ hint.action();
24
+ return;
25
+ }
26
+ start = end + 3;
27
+ }
28
+ });
29
+ }
@@ -0,0 +1,9 @@
1
+ /** JSON and caught failures cross the process/filesystem boundary as unknown values. */
2
+ export type UnknownRecord = Readonly<Record<string, unknown>>;
3
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
4
+ export declare function isUnknownArray(value: unknown): value is readonly unknown[];
5
+ export declare function recordAt(value: unknown, key: string | number): UnknownRecord | undefined;
6
+ export declare function errorMessage(error: unknown): string;
7
+ export declare function hasErrorCode(error: unknown, code: string): boolean;
8
+ /** Retain native error names without letting a broken stringifier replace the caught failure. */
9
+ export declare function errorDescription(error: unknown): string;
@@ -34,6 +34,8 @@ tests. CI separately qualifies the latest official Pi on GitHub-hosted Linux and
34
34
  the maintained fork on Linux and macOS. There is no custom checker build, probe
35
35
  suite or self-hosted runner requirement.
36
36
 
37
- CI's lint job also enforces the production line/byte shrink budget before
38
- qualification substitutes the selected host graph. The retired workflow schema
39
- generator and its patching dependency are not part of the new runtime.
37
+ CI's lint job also enforces the production byte ceiling before qualification
38
+ substitutes the selected host graph. The retired workflow schema generator and
39
+ its patching dependency are not part of the new runtime. `npm run check:size`
40
+ still reports source and production line counts for visibility; no line count
41
+ is mandated. The 1 MiB byte ceiling and all lint limits stay unchanged.
@@ -1,5 +1,10 @@
1
1
  # Development
2
2
 
3
+ The supported Intercom client's package export uses emitted declarations for consumers.
4
+ The repository's native `development` type condition points at its TypeScript source so
5
+ cold lint and type checks do not require a prior build. Packed-consumer checks compile
6
+ against the default declaration export without that condition or private path aliases.
7
+
3
8
  Use a dedicated worktree, the committed npm lockfile, and a ready-for-review PR.
4
9
  Never edit source or tests while checks run in that checkout. Do not modify an
5
10
  active installed copy or reload the user's sessions to test a candidate.
@@ -16,8 +21,42 @@ Run narrow owner-boundary tests locally through `scripts/run-tests.mjs`. Full
16
21
  `npm run ci` belongs in hosted CI and includes package/install smokes and the
17
22
  complete suites. Keep the strict Oxlint/Oxfmt rules; see [code quality](code-quality.md).
18
23
 
24
+ ## Native Android/Termux
25
+
26
+ Use app-private storage, not Android's shared `/sdcard` filesystem. No Termux app
27
+ patch, privileged access, or Android security-policy change is required.
28
+
29
+ TypeScript 7.0.2 does not publish its Android ARM64 npm compiler package. Source
30
+ installs, `build`, and `typecheck` therefore use an Android-native `tsgo` on PATH;
31
+ other platforms keep using the committed npm TypeScript version. Install a native
32
+ build from Microsoft's maintained [TypeScript source](https://github.com/microsoft/typescript-go)
33
+ with Android support. A Linux ARM64 binary is not an Android substitute. The
34
+ prebuilt npm package needs no compiler.
35
+
36
+ The package uses Termux's shared `$PREFIX/tmp` for its default broker and
37
+ observation endpoints, independently of individual sessions' HOME or TMPDIR.
38
+ Other Android environments without PREFIX use Node's native temporary directory.
39
+ Tests keep their isolated sockets short enough for the same 100-byte path limit.
40
+
41
+ Android process-reuse checks compare native elapsed age with a saved monotonic
42
+ bound, qualified by the real boot ID and time namespace, rather than wall clocks
43
+ or `ps` boot-epoch dates. Keep the conservative second/tick allowance and reject
44
+ missing or incomparable clock evidence. Legacy records without that evidence
45
+ retain fail-closed identity checks; do not bypass unavailable procfs PID-namespace
46
+ guards or invent a namespace.
47
+
48
+ The published Oxlint type-aware backend currently has no Android package. Keep
49
+ the canonical lint command and rules intact and obtain its passing evidence in
50
+ hosted CI; do not substitute a weakened checker or incompatible Linux executable.
51
+
19
52
  ## Runtime qualification
20
53
 
54
+ The official development SDK 1.4.0 pins Undici 8.10.2 exactly. A parent-scoped
55
+ override selects 8.12.3 for that development graph to fix GHSA-4qv8-2p9j-2685,
56
+ GHSA-f72v-6w45-q472 and GHSA-xjj6-f33q-929c. Remove the override when an official
57
+ SDK release supplies a fixed version. It does not replace a consumer's host SDK
58
+ or Node's built-in fetch.
59
+
21
60
  Qualify the latest stable official Pi and the intended fork revision separately.
22
61
  Record the exact package root, revision/build, Node binary, and executed test
23
62
  commands. Matching versions do not establish matching runtime behavior.
@@ -52,9 +91,9 @@ negative assertions. A sleep does not prove an owner processed its input.
52
91
 
53
92
  `scripts/measure-size.mjs` counts source, production scripts, generated code and
54
93
  schemas, including new untracked files. `--report` prints without enforcing.
55
- The ceilings are 16,000 source TypeScript lines, 20,000 total production lines,
56
- and 1 MiB total production bytes. Tests have no deletion quota. Moving machinery
57
- into generated/minified output does not evade the gate.
94
+ The only enforced ceiling is 1 MiB total production bytes; source and production
95
+ line counts are reported for visibility, not mandated. Tests have no deletion
96
+ quota. Moving machinery into generated/minified output does not evade the gate.
58
97
 
59
98
  [The rebuild evidence](single-agent-rebuild.md) records the baseline, confirmed
60
99
  design, test-transfer obligations, and exact selected host identities. Update it
@@ -122,10 +122,15 @@ retries. A lost response is **UNKNOWN**, not proof that a message was unsent.
122
122
  | `POST /v1/ack` | `{ids}` (at most 256) | `acked`; durable intake ownership transferred |
123
123
 
124
124
  `to` uses the existing exact-ID/name resolver; ambiguity never selects a peer.
125
- Explicit message IDs survive byte-for-byte. `passive:true` is mailbox-only and
126
- never wakes. Acceptance means transport/broker admission, not native journal
127
- persistence, model start, reply completion or task completion. A bare `ok:true`
128
- is not an acceptance receipt.
125
+ Explicit message IDs survive byte-for-byte. Use a fresh `--id` / `messageId` for
126
+ each new logical message; reuse it only to retry that same message. Updated native
127
+ recipients ignore an already-admitted ID even if a retry changes its payload,
128
+ while the broker can still report transport acceptance. Do not reuse a previous
129
+ ID to send a different instruction.
130
+
131
+ `passive:true` is mailbox-only and never wakes. Acceptance means transport/broker
132
+ admission, not native journal persistence, model start, reply completion or task
133
+ completion. A bare `ok:true` is not an acceptance receipt.
129
134
 
130
135
  The maintained client is `scripts/intercom-remote.mjs`. Copy it unchanged with
131
136
  only the CA, client certificate and private key to a private remote directory.