@fitchmultz/pi-subagents 0.50.0 → 0.51.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 (169) hide show
  1. package/CHANGELOG.md +51 -2
  2. package/README.md +36 -5
  3. package/dist/agent/contracts.js +6 -24
  4. package/dist/agent/host.js +3 -16
  5. package/dist/agent/index.js +207 -173
  6. package/dist/agent/journal-tail.js +115 -156
  7. package/dist/agent/launch.js +20 -8
  8. package/dist/agent/legacy.js +65 -23
  9. package/dist/agent/notifications.js +138 -47
  10. package/dist/agent/observation-state.js +246 -0
  11. package/dist/agent/observation-view.js +258 -0
  12. package/dist/agent/observation.js +346 -0
  13. package/dist/agent/observe-card.js +243 -0
  14. package/dist/agent/observe-chrome.js +85 -0
  15. package/dist/agent/observe-client.js +285 -0
  16. package/dist/agent/observe-layout.js +65 -0
  17. package/dist/agent/observe-reading.js +84 -0
  18. package/dist/agent/observe-screen.js +400 -96
  19. package/dist/agent/observe-transcript.js +382 -0
  20. package/dist/agent/observe.js +250 -43
  21. package/dist/agent/process.js +198 -25
  22. package/dist/agent/receipts.js +92 -20
  23. package/dist/agent/runner.js +125 -20
  24. package/dist/agent/shell.js +2 -1
  25. package/dist/agent/storage.js +29 -12
  26. package/dist/agent/tool-schemas.js +20 -0
  27. package/dist/agent/typebox.js +10 -5
  28. package/dist/agent/usage.js +0 -115
  29. package/dist/agent/view.js +46 -16
  30. package/dist/agent/wake.js +99 -0
  31. package/dist/build-info.js +1 -1
  32. package/dist/intercom-client.d.ts +26 -0
  33. package/dist/intercom-client.js +45 -0
  34. package/dist/pi-intercom/agent-dir.d.ts +1 -0
  35. package/dist/pi-intercom/bridge-session.js +2 -1
  36. package/dist/pi-intercom/broker/broker.js +27 -21
  37. package/dist/pi-intercom/broker/client-last.d.ts +13 -0
  38. package/dist/pi-intercom/broker/client-last.js +17 -5
  39. package/dist/pi-intercom/broker/client.d.ts +60 -0
  40. package/dist/pi-intercom/broker/client.js +47 -16
  41. package/dist/pi-intercom/broker/connection.d.ts +29 -0
  42. package/dist/pi-intercom/broker/framing.d.ts +17 -0
  43. package/dist/pi-intercom/broker/framing.js +10 -7
  44. package/dist/pi-intercom/broker/guard.js +23 -0
  45. package/dist/pi-intercom/broker/last.d.ts +26 -0
  46. package/dist/pi-intercom/broker/last.js +57 -32
  47. package/dist/pi-intercom/broker/paths.d.ts +13 -0
  48. package/dist/pi-intercom/broker/paths.js +35 -12
  49. package/dist/pi-intercom/broker/pid.js +5 -1
  50. package/dist/pi-intercom/broker/server-connection.js +19 -0
  51. package/dist/pi-intercom/broker/snapshot-reader.d.ts +9 -0
  52. package/dist/pi-intercom/broker/snapshots.d.ts +29 -0
  53. package/dist/pi-intercom/broker/targets.d.ts +9 -0
  54. package/dist/pi-intercom/connection.js +10 -2
  55. package/dist/pi-intercom/identity.js +6 -5
  56. package/dist/pi-intercom/inbound-delivery.js +16 -60
  57. package/dist/pi-intercom/inbound-journal.js +83 -6
  58. package/dist/pi-intercom/inbound-record.js +18 -1
  59. package/dist/pi-intercom/inbound.js +12 -38
  60. package/dist/pi-intercom/index.js +3 -0
  61. package/dist/pi-intercom/last-message.js +4 -16
  62. package/dist/pi-intercom/lifecycle.js +88 -3
  63. package/dist/pi-intercom/outbound.js +5 -15
  64. package/dist/pi-intercom/reply-tracker.js +7 -6
  65. package/dist/pi-intercom/reply-wait.js +2 -2
  66. package/dist/pi-intercom/session-targets.d.ts +24 -0
  67. package/dist/pi-intercom/tool-registration.js +4 -1
  68. package/dist/pi-intercom/tool-results.js +5 -7
  69. package/dist/pi-intercom/tool-schemas.js +12 -26
  70. package/dist/pi-intercom/types.d.ts +212 -0
  71. package/dist/pi-intercom/types.js +54 -3
  72. package/dist/pi-intercom/validation.d.ts +12 -0
  73. package/dist/pi-intercom/validation.js +3 -0
  74. package/dist/shared/bounded-json.js +151 -0
  75. package/dist/shared/compact-cards.js +66 -0
  76. package/dist/shared/journal-frames.js +3 -6
  77. package/dist/shared/json-projection.js +267 -129
  78. package/dist/shared/native-journal.js +123 -56
  79. package/dist/shared/post-exit-stdio-guard.js +120 -7
  80. package/dist/shared/tui-action.js +29 -0
  81. package/dist/shared/unknown.d.ts +9 -0
  82. package/docs/code-quality.md +2 -0
  83. package/docs/development.md +40 -1
  84. package/docs/intercom-bridge.md +9 -4
  85. package/docs/intercom.md +226 -6
  86. package/docs/performance.md +87 -11
  87. package/docs/reference.md +157 -20
  88. package/docs/retired-history-maintenance.md +252 -0
  89. package/package.json +28 -15
  90. package/scripts/archive-retired-history.mjs +464 -0
  91. package/scripts/build.mjs +48 -28
  92. package/scripts/measure-size.mjs +1 -1
  93. package/scripts/native-package-smoke.mjs +153 -12
  94. package/scripts/package-smoke.mjs +80 -11
  95. package/scripts/prepare.mjs +2 -2
  96. package/scripts/real-pi-live.mjs +377 -0
  97. package/scripts/real-pi-processes.mjs +6 -1
  98. package/scripts/real-pi-smoke.mjs +6 -282
  99. package/scripts/run-tests.mjs +4 -2
  100. package/scripts/typescript.mjs +35 -0
  101. package/skills/pi-intercom/SKILL.md +2 -1
  102. package/skills/pi-subagents/SKILL.md +23 -5
  103. package/src/agent/contracts.ts +8 -34
  104. package/src/agent/host.ts +19 -20
  105. package/src/agent/index.ts +224 -190
  106. package/src/agent/journal-tail.ts +141 -153
  107. package/src/agent/launch.ts +33 -9
  108. package/src/agent/legacy.ts +85 -31
  109. package/src/agent/notifications.ts +192 -67
  110. package/src/agent/observation-state.ts +277 -0
  111. package/src/agent/observation-view.ts +267 -0
  112. package/src/agent/observation.ts +405 -0
  113. package/src/agent/observe-card.ts +304 -0
  114. package/src/agent/observe-chrome.ts +170 -0
  115. package/src/agent/observe-client.ts +318 -0
  116. package/src/agent/observe-layout.ts +79 -0
  117. package/src/agent/observe-reading.ts +97 -0
  118. package/src/agent/observe-screen.ts +443 -129
  119. package/src/agent/observe-transcript.ts +434 -0
  120. package/src/agent/observe.ts +304 -71
  121. package/src/agent/process.ts +240 -34
  122. package/src/agent/receipts.ts +114 -24
  123. package/src/agent/runner.ts +125 -27
  124. package/src/agent/shell.ts +2 -1
  125. package/src/agent/storage.ts +27 -11
  126. package/src/agent/tool-schemas.ts +33 -0
  127. package/src/agent/typebox.ts +12 -7
  128. package/src/agent/usage.ts +0 -130
  129. package/src/agent/view.ts +70 -20
  130. package/src/agent/wake.ts +107 -0
  131. package/src/intercom-client.ts +108 -0
  132. package/src/pi-intercom/bridge-session.ts +2 -2
  133. package/src/pi-intercom/broker/broker.ts +27 -23
  134. package/src/pi-intercom/broker/client-last.ts +25 -12
  135. package/src/pi-intercom/broker/client.ts +55 -33
  136. package/src/pi-intercom/broker/framing.ts +11 -8
  137. package/src/pi-intercom/broker/guard.ts +33 -0
  138. package/src/pi-intercom/broker/last.ts +86 -33
  139. package/src/pi-intercom/broker/paths.ts +39 -12
  140. package/src/pi-intercom/broker/pid.ts +6 -1
  141. package/src/pi-intercom/broker/server-connection.ts +25 -0
  142. package/src/pi-intercom/connection.ts +21 -10
  143. package/src/pi-intercom/identity.ts +8 -5
  144. package/src/pi-intercom/inbound-delivery.ts +20 -66
  145. package/src/pi-intercom/inbound-journal.ts +103 -7
  146. package/src/pi-intercom/inbound-record.ts +21 -4
  147. package/src/pi-intercom/inbound.ts +18 -49
  148. package/src/pi-intercom/index.ts +3 -0
  149. package/src/pi-intercom/last-message.ts +10 -19
  150. package/src/pi-intercom/lifecycle.ts +117 -14
  151. package/src/pi-intercom/outbound.ts +7 -16
  152. package/src/pi-intercom/reply-tracker.ts +8 -6
  153. package/src/pi-intercom/reply-wait.ts +3 -2
  154. package/src/pi-intercom/runtime-types.ts +0 -1
  155. package/src/pi-intercom/tool-registration.ts +4 -1
  156. package/src/pi-intercom/tool-results.ts +10 -9
  157. package/src/pi-intercom/tool-schemas.ts +12 -44
  158. package/src/pi-intercom/types.ts +115 -4
  159. package/src/pi-intercom/validation.ts +4 -0
  160. package/src/shared/bounded-json.ts +162 -0
  161. package/src/shared/compact-cards.ts +118 -0
  162. package/src/shared/journal-frames.ts +3 -19
  163. package/src/shared/json-projection.ts +267 -147
  164. package/src/shared/native-journal.ts +157 -75
  165. package/src/shared/post-exit-stdio-guard.ts +165 -7
  166. package/src/shared/tui-action.ts +39 -0
  167. package/tsconfig.client.json +8 -0
  168. package/tsconfig.json +1 -0
  169. package/src/shared/stream-json.d.ts +0 -10
@@ -1,7 +1,8 @@
1
1
  import * as fs from "node:fs";
2
- import { scanJournal, type JournalPolicy, type JournalRecord } from "./journal-frames.ts";
2
+ import { JournalFrames, type JournalPolicy, type JournalRecord } from "./journal-frames.ts";
3
+ import type { Projection, StringChunk } from "./json-projection.ts";
3
4
  import { nativeProjection } from "./journal-projections.ts";
4
- import { isRecord, recordAt, type UnknownRecord } from "./unknown.ts";
5
+ import { isRecord, type UnknownRecord } from "./unknown.ts";
5
6
 
6
7
  /** A selected journal entry is not a complete SDK SessionEntry or Message. */
7
8
  export interface NativePreview extends UnknownRecord {
@@ -33,77 +34,169 @@ function preview(
33
34
  };
34
35
  return { value, start: record.start, end: record.end };
35
36
  }
36
- function configurationModel(value: NativePreview): string | undefined {
37
- const message = recordAt(value, "message");
38
- const provider = value.type === "model_change" ? value.provider : message?.provider;
39
- const model = value.type === "model_change" ? value.modelId : message?.model;
40
- return typeof provider === "string" && typeof model === "string"
41
- ? `${provider}/${model}`
42
- : undefined;
43
- }
44
37
  export class NativeJournal {
45
38
  readonly records: NativeRecord[] = [];
46
39
  readonly byId = new Map<string, NativeRecord>();
47
- readonly stamp: string;
48
- readonly end: number;
40
+ stamp: string;
41
+ end: number;
49
42
  readonly file: string;
50
- constructor(file: string, policy: JournalPolicy = "inspect", requireNewline = policy === "live") {
43
+ private readonly identity: string;
44
+ private offset = 0;
45
+ private readonly frames: JournalFrames;
46
+ constructor(
47
+ file: string,
48
+ policy: JournalPolicy = "inspect",
49
+ requireNewline = policy === "live",
50
+ options: { readBytes?: number; select?: Projection } = {},
51
+ ) {
51
52
  this.file = file;
52
- const fd = fs.openSync(file, "r");
53
+ let parent: string | null = null;
54
+ this.frames = new JournalFrames(
55
+ options.select ?? nativeProjection,
56
+ (raw) => {
57
+ const record = preview(raw, parent, this.records[0]?.value.version === 1);
58
+ if (!record) {
59
+ if (policy !== "inspect") {
60
+ throw new SyntaxError(`Native record at byte ${raw.start} requires a type`);
61
+ }
62
+ return;
63
+ }
64
+ if (record.value.type !== "session") {
65
+ parent = record.value.id;
66
+ }
67
+ this.records.push(record);
68
+ this.byId.set(record.value.id, record);
69
+ },
70
+ { policy, requireNewline },
71
+ );
72
+ const fd = fs.openSync(
73
+ file,
74
+ fs.constants.O_RDONLY | (policy === "live" ? fs.constants.O_NOFOLLOW : 0),
75
+ );
53
76
  try {
54
77
  const stat = fs.fstatSync(fd, { bigint: true });
55
78
  this.stamp = journalStamp(stat);
79
+ this.identity = `${stat.dev}:${stat.ino}`;
56
80
  this.end = Number(stat.size);
57
- let parent: string | null = null;
58
- scanJournal(
81
+ this.index(fd, Math.min(this.end, options.readBytes ?? Infinity));
82
+ this.checkInitial(stat, fs.fstatSync(fd, { bigint: true }), policy);
83
+ } finally {
84
+ fs.closeSync(fd);
85
+ }
86
+ if (this.records[0]?.value.type !== "session") {
87
+ throw new Error("Not a readable native Pi session.");
88
+ }
89
+ }
90
+ private checkInitial(before: fs.BigIntStats, after: fs.BigIntStats, policy: JournalPolicy): void {
91
+ if (
92
+ journalStamp(after) !== this.stamp &&
93
+ (policy !== "live" ||
94
+ after.size < before.size ||
95
+ `${after.dev}:${after.ino}` !== this.identity)
96
+ ) {
97
+ throw new Error("Journal changed while indexing; refresh history.");
98
+ }
99
+ }
100
+ private index(fd: number, end: number): void {
101
+ const buffer = Buffer.allocUnsafe(65536);
102
+ while (this.offset < end) {
103
+ const count = fs.readSync(
59
104
  fd,
60
- nativeProjection,
61
- (raw) => {
62
- const record = preview(raw, parent, this.records[0]?.value.version === 1);
63
- if (!record) {
64
- if (policy !== "inspect") {
65
- throw new SyntaxError(`Native record at byte ${raw.start} requires a type`);
66
- }
67
- return;
68
- }
69
- if (record.value.type !== "session") {
70
- parent = record.value.id;
71
- }
72
- this.records.push(record);
73
- this.byId.set(record.value.id, record);
74
- },
75
- { policy, requireNewline, end: this.end },
105
+ buffer,
106
+ 0,
107
+ Math.min(buffer.length, end - this.offset),
108
+ this.offset,
76
109
  );
77
- if (journalStamp(fs.fstatSync(fd, { bigint: true })) !== this.stamp) {
78
- throw new Error("Journal changed while indexing; refresh history.");
110
+ if (count === 0) {
111
+ throw new Error("Journal truncated while reading");
112
+ }
113
+ this.frames.write(buffer.subarray(0, count));
114
+ this.offset += count;
115
+ }
116
+ this.frames.finish();
117
+ }
118
+ get indexed(): boolean {
119
+ return this.offset === this.end;
120
+ }
121
+ /** Incremental, LF-published indexing. Append work is budgeted independently of paint. */
122
+ refresh(bytes = 256 * 1024): boolean {
123
+ const fd = fs.openSync(this.file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
124
+ try {
125
+ const stat = fs.fstatSync(fd, { bigint: true });
126
+ if (`${stat.dev}:${stat.ino}` !== this.identity || Number(stat.size) < this.offset) {
127
+ throw new Error("Native journal was replaced or truncated; reopen this conversation.");
128
+ }
129
+ if (journalStamp(stat) === this.stamp && this.indexed) {
130
+ return false;
79
131
  }
132
+ const previous = this.records.length;
133
+ this.end = Number(stat.size);
134
+ this.index(fd, Math.min(this.end, this.offset + bytes));
135
+ this.stamp = journalStamp(stat);
136
+ return previous !== this.records.length;
80
137
  } finally {
81
138
  fs.closeSync(fd);
82
139
  }
83
- if (this.records[0]?.value.type !== "session") {
84
- throw new Error("Not a readable native Pi session.");
140
+ }
141
+ /** Project one immutable, published record, including bounded slices of very large strings. */
142
+ async project(
143
+ record: NativeRecord,
144
+ select: Projection,
145
+ stringChunk?: StringChunk,
146
+ signal?: AbortSignal,
147
+ ): Promise<UnknownRecord> {
148
+ const fd = fs.openSync(this.file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
149
+ let stream: fs.ReadStream | undefined;
150
+ try {
151
+ const stat = fs.fstatSync(fd, { bigint: true });
152
+ if (`${stat.dev}:${stat.ino}` !== this.identity || Number(stat.size) < record.end) {
153
+ throw new Error("Native journal was replaced or truncated.");
154
+ }
155
+ let value: UnknownRecord | undefined;
156
+ const frames = new JournalFrames(
157
+ select,
158
+ (entry) => {
159
+ value = entry.value;
160
+ },
161
+ { policy: "live", start: record.start, stringChunk },
162
+ );
163
+ stream = fs.createReadStream(this.file, {
164
+ fd,
165
+ start: record.start,
166
+ end: record.end - 1,
167
+ highWaterMark: 65536,
168
+ signal,
169
+ });
170
+ for await (const chunk of stream) {
171
+ if (!Buffer.isBuffer(chunk)) {
172
+ throw new TypeError("Native journal reads require bytes.");
173
+ }
174
+ frames.write(chunk);
175
+ }
176
+ frames.finish();
177
+ if (!value) {
178
+ throw new Error("Native entry is not published.");
179
+ }
180
+ return value;
181
+ } finally {
182
+ if (stream) {
183
+ stream.destroy();
184
+ } else {
185
+ fs.closeSync(fd);
186
+ }
85
187
  }
86
188
  }
87
- branch(leaf?: string | null, endedAt?: number): NativeRecord[] {
189
+ branch(leaf?: string | null): NativeRecord[] {
88
190
  if (leaf === null) {
89
191
  return [];
90
192
  }
91
- const eligible =
92
- endedAt === undefined
93
- ? this.records
94
- : this.records.filter(
95
- (record) =>
96
- record.value.type === "session" ||
97
- Date.parse(record.value.timestamp ?? "") <= endedAt,
98
- );
99
193
  let current =
100
194
  leaf !== undefined && leaf !== ""
101
195
  ? this.byId.get(leaf)
102
- : eligible.findLast((record) => record.value.type !== "session");
196
+ : this.records.findLast((record) => record.value.type !== "session");
103
197
  const path: NativeRecord[] = [];
104
198
  const seen = new Set<string>();
105
- const allowed = new Set(eligible.map((record) => record.value.id));
106
- while (current && current.value.type !== "session" && allowed.has(current.value.id)) {
199
+ while (current && current.value.type !== "session") {
107
200
  if (seen.has(current.value.id)) {
108
201
  throw new Error("Cyclic journal ancestry");
109
202
  }
@@ -139,33 +232,22 @@ export class NativeJournal {
139
232
  fs.closeSync(fd);
140
233
  }
141
234
  }
142
- configuration(
143
- endedAt?: number,
144
- leaf?: string | null,
145
- ): {
146
- model?: string;
147
- thinking?: string;
148
- modelRecordedAt?: number;
149
- } {
150
- const result: ReturnType<NativeJournal["configuration"]> = {};
151
- let model: NativePreview | undefined;
152
- for (const { value } of this.branch(leaf, endedAt)) {
153
- if (value.type === "thinking_level_change" && typeof value.thinkingLevel === "string") {
154
- result.thinking = value.thinkingLevel;
155
- }
156
- if (
157
- value.type === "model_change" ||
158
- (value.type === "message" && recordAt(value, "message")?.role === "assistant")
159
- ) {
160
- model = value;
161
- }
162
- }
163
- if (model) {
164
- result.model = configurationModel(model);
165
- result.modelRecordedAt = Date.parse(model.timestamp ?? "");
166
- }
167
- return result;
168
- }
235
+ }
236
+ /** Native v3 writers append after attachment; loading older formats can rewrite in place.
237
+ * Outside prefix edits followed by same-inode growth are not an append-only operation.
238
+ */
239
+ export function nativeAppend(
240
+ before: fs.BigIntStats,
241
+ after: fs.BigIntStats,
242
+ header: UnknownRecord | undefined,
243
+ ): boolean {
244
+ return (
245
+ header?.type === "session" &&
246
+ header.version === 3 &&
247
+ before.dev === after.dev &&
248
+ before.ino === after.ino &&
249
+ after.size > before.size
250
+ );
169
251
  }
170
252
  export function journalStamp(stat: fs.BigIntStats): string {
171
253
  return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
@@ -1,7 +1,10 @@
1
- import { execFileSync, type ChildProcess } from "node:child_process";
2
- import { readLinuxProcess } from "../pi-intercom/broker/pid.ts";
1
+ import { execFile, execFileSync, type ChildProcess } from "node:child_process";
2
+ import { readFileSync, readlinkSync } from "node:fs";
3
+ import { isLinuxProcessReplaced, readLinuxProcess } from "../pi-intercom/broker/pid.ts";
3
4
  import { hasErrorCode } from "./unknown.ts";
4
5
 
6
+ const BIRTH_FIELDS = ["lstart=", "pgid="];
7
+
5
8
  interface AgentProcessExit {
6
9
  readonly pid?: number;
7
10
  readonly code: number | null;
@@ -20,6 +23,31 @@ interface ChildWithKill {
20
23
  readonly kill: (signal?: NodeJS.Signals | number) => boolean;
21
24
  }
22
25
 
26
+ interface ProcessClock {
27
+ readonly boot: string;
28
+ readonly namespace: string;
29
+ readonly at: string;
30
+ }
31
+
32
+ /** Capture only a real, cross-process clock context; unavailable context proves nothing. */
33
+ export function readProcessClock(): ProcessClock | undefined {
34
+ if (process.platform !== "android") {
35
+ return undefined;
36
+ }
37
+ try {
38
+ const boot = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
39
+ const namespace = readlinkSync("/proc/self/ns/time");
40
+ const at = process.hrtime.bigint();
41
+ return at > 0n &&
42
+ /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/.test(boot) &&
43
+ /^time:\[\d+\]$/.test(namespace)
44
+ ? { boot, namespace, at: at.toString() }
45
+ : undefined;
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ }
50
+
23
51
  /** Capture the detached group leader's birth identity; Pi may replace its command-line title. */
24
52
  export function readChildProcessIdentity(pid: number): string | undefined {
25
53
  if (!Number.isSafeInteger(pid) || pid <= 0) {
@@ -28,21 +56,151 @@ export function readChildProcessIdentity(pid: number): string | undefined {
28
56
  if (process.platform === "linux") {
29
57
  return readLinuxProcess(pid)?.identity;
30
58
  }
59
+ return groupLeader(pid, readPsFields(pid, BIRTH_FIELDS));
60
+ }
61
+
62
+ function groupLeader(pid: number, birth: string | undefined): string | undefined {
63
+ return Number(birth?.split(" ").at(-1)) === pid ? birth : undefined;
64
+ }
65
+
66
+ // Always one PID: Darwin ps given several PIDs scans every process at many times the cost.
67
+ function psArguments(pid: number, fields: readonly string[]): string[] {
68
+ return ["-p", String(pid), ...fields.flatMap((field) => ["-o", field])];
69
+ }
70
+
71
+ function psOptions(zone: string | undefined) {
72
+ const env = { ...process.env, LC_ALL: "C", ...(zone === undefined ? {} : { TZ: zone }) };
73
+ return { encoding: "utf8" as const, timeout: 1000, env };
74
+ }
75
+
76
+ function readPsFields(pid: number, fields: readonly string[], zone?: string): string | undefined {
31
77
  try {
32
- const identity = execFileSync("ps", ["-p", String(pid), "-o", "lstart=", "-o", "pgid="], {
33
- encoding: "utf8",
34
- timeout: 1000,
78
+ return execFileSync("ps", psArguments(pid, fields), {
79
+ ...psOptions(zone),
35
80
  stdio: ["ignore", "pipe", "ignore"],
36
- env: { ...process.env, LC_ALL: "C" },
37
81
  })
38
82
  .trim()
39
83
  .replace(/\s+/g, " ");
40
- return Number(identity.split(" ").at(-1)) === pid ? identity : undefined;
41
84
  } catch {
42
85
  return undefined;
43
86
  }
44
87
  }
45
88
 
89
+ /** Nonblocking `readPsFields`; a failed run likewise observes nothing. */
90
+ function observePsFields(
91
+ pid: number,
92
+ fields: readonly string[],
93
+ zone?: string,
94
+ ): Promise<string | undefined> {
95
+ return new Promise((resolve) => {
96
+ execFile("ps", psArguments(pid, fields), psOptions(zone), (error, stdout) => {
97
+ resolve(error ? undefined : stdout.trim().replace(/\s+/g, " "));
98
+ });
99
+ });
100
+ }
101
+
102
+ // C-locale ps lstart (whole-second birth, in the reader's zone), then the process group.
103
+ 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+$/;
104
+ const MONTHS = "JanFebMarAprMayJunJulAugSepOctNovDec";
105
+
106
+ /** Parses a birth observed with TZ=UTC0. */
107
+ function utcBirth(birth: string | undefined): number {
108
+ const [, month = "", day, hours, minutes, seconds, year] = PS_IDENTITY.exec(birth ?? "") ?? [];
109
+ const index = MONTHS.indexOf(month);
110
+ // Unreadable or malformed observations yield NaN, which never proves a later birth.
111
+ return month.length === 3 && index % 3 === 0
112
+ ? Date.UTC(
113
+ Number(year),
114
+ index / 3,
115
+ Number(day),
116
+ Number(hours),
117
+ Number(minutes),
118
+ Number(seconds),
119
+ )
120
+ : Number.NaN;
121
+ }
122
+
123
+ function sameClock(left: ProcessClock, right: ProcessClock): boolean {
124
+ return left.boot === right.boot && left.namespace === right.namespace;
125
+ }
126
+
127
+ /** Android's elapsed age bounds birth in the saved monotonic clock, never wall time. */
128
+ function clockBound(clock: ProcessClock | undefined) {
129
+ const before = readProcessClock();
130
+ return clock !== undefined && before !== undefined && sameClock(clock, before)
131
+ ? { clock, before }
132
+ : undefined;
133
+ }
134
+
135
+ /** `elapsed` is observed after the bound's `before` read; the closing clock read follows both. */
136
+ function bornAfter(
137
+ { clock, before }: { clock: ProcessClock; before: ProcessClock },
138
+ elapsed = "",
139
+ ): boolean {
140
+ const after = readProcessClock();
141
+ if (after === undefined || !sameClock(before, after) || !/^\d+$/.test(elapsed)) {
142
+ return false;
143
+ }
144
+ // Include displayed-second and native tick uncertainty. BOOTTIME age includes suspend,
145
+ // unlike hrtime's MONOTONIC clock, so suspend only moves this lower bound earlier.
146
+ const birth = BigInt(before.at) - (BigInt(elapsed) + 2n) * 1_000_000_000n;
147
+ return BigInt(after.at) >= BigInt(before.at) && birth > BigInt(clock.at);
148
+ }
149
+
150
+ /**
151
+ * True only when a live PID provably names a different process than an owner-published record.
152
+ * Darwin identities are unzoned local times, so they are never compared across zones; only a
153
+ * UTC-observed birth strictly after the record's publication proves replacement. Android uses
154
+ * a conservative elapsed-age bound against its recorded boot-qualified monotonic clock.
155
+ */
156
+ export function isChildProcessReplaced(
157
+ pid: number,
158
+ recorded: string,
159
+ published?: number,
160
+ clock?: ProcessClock,
161
+ ): boolean {
162
+ if (process.platform === "linux") {
163
+ return isLinuxProcessReplaced(pid, recorded);
164
+ }
165
+ if (!PS_IDENTITY.test(recorded)) {
166
+ return false;
167
+ }
168
+ if (process.platform === "android") {
169
+ const bound = clockBound(clock);
170
+ return bound !== undefined && bornAfter(bound, readPsFields(pid, ["etimes="]));
171
+ }
172
+ return published !== undefined && utcBirth(readPsFields(pid, BIRTH_FIELDS, "UTC0")) > published;
173
+ }
174
+
175
+ /**
176
+ * Nonblocking `readChildProcessIdentity(pid) !== recorded && isChildProcessReplaced(...)` for
177
+ * routine liveness. Signals still need the fresh synchronous proof.
178
+ */
179
+ export async function observeChildProcessReplaced(
180
+ pid: number,
181
+ recorded: string,
182
+ published?: number,
183
+ clock?: ProcessClock,
184
+ ): Promise<boolean> {
185
+ if (process.platform === "linux") {
186
+ return readChildProcessIdentity(pid) !== recorded && isLinuxProcessReplaced(pid, recorded);
187
+ }
188
+ if (
189
+ !PS_IDENTITY.test(recorded) ||
190
+ groupLeader(pid, await observePsFields(pid, BIRTH_FIELDS)) === recorded
191
+ ) {
192
+ return false;
193
+ }
194
+ if (process.platform === "android") {
195
+ const bound = clockBound(clock);
196
+ return bound !== undefined && bornAfter(bound, await observePsFields(pid, ["etimes="]));
197
+ }
198
+ if (published === undefined) {
199
+ return false;
200
+ }
201
+ return utcBirth(await observePsFields(pid, BIRTH_FIELDS, "UTC0")) > published;
202
+ }
203
+
46
204
  function trySignalChild(child: ChildWithKill, signal: NodeJS.Signals): boolean {
47
205
  try {
48
206
  return child.kill(signal);
@@ -0,0 +1,39 @@
1
+ import {
2
+ MouseRegion,
3
+ TruncatedText,
4
+ truncateToWidth,
5
+ visibleWidth,
6
+ type Component,
7
+ } from "@earendil-works/pi-tui";
8
+
9
+ export function actionText(text: string, action: (column: number) => void): Component {
10
+ const component = new TruncatedText(text, 0, 0);
11
+ return new MouseRegion(component, (event) => {
12
+ if (
13
+ (event.type !== "press" && event.type !== "click") ||
14
+ event.button !== "left" ||
15
+ event.x >= visibleWidth(truncateToWidth(text, event.width))
16
+ ) {
17
+ return;
18
+ }
19
+ if (event.type === "click") {
20
+ action(event.x);
21
+ }
22
+ return { handled: true, render: event.type === "click" };
23
+ });
24
+ }
25
+
26
+ /** Each visible hint dispatches the same action as its corresponding keyboard binding. */
27
+ export function actionHints(hints: readonly { text: string; action: () => void }[]): Component {
28
+ return actionText(hints.map((hint) => hint.text).join(" · "), (column) => {
29
+ let start = 0;
30
+ for (const hint of hints) {
31
+ const end = start + visibleWidth(hint.text);
32
+ if (column >= start && column < end) {
33
+ hint.action();
34
+ return;
35
+ }
36
+ start = end + 3;
37
+ }
38
+ });
39
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.build.json",
3
+ "compilerOptions": {
4
+ "declaration": true,
5
+ "emitDeclarationOnly": true
6
+ },
7
+ "include": ["src/intercom-client.ts"]
8
+ }
package/tsconfig.json CHANGED
@@ -4,6 +4,7 @@
4
4
  "lib": ["ES2025"],
5
5
  "module": "NodeNext",
6
6
  "moduleResolution": "NodeNext",
7
+ "customConditions": ["development"],
7
8
  "allowImportingTsExtensions": true,
8
9
  "verbatimModuleSyntax": true,
9
10
  "erasableSyntaxOnly": true,
@@ -1,10 +0,0 @@
1
- import type { Many, none } from "stream-chain/defs.js";
2
- import type { ParserOptions, Token } from "stream-json/core/parser.js";
3
-
4
- // stream-json 3.7.0 exports the synchronous tokenizer used by its default
5
- // generator adapter, but omits this export from parser.d.ts.
6
- declare module "stream-json/core/parser.js" {
7
- export function jsonParser(
8
- options?: Readonly<ParserOptions>,
9
- ): (chunk: string | typeof none) => Many<Token> | typeof none;
10
- }