@ctliz/agent-intercom-codex 0.11.0-connect.2

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 (52) hide show
  1. package/.codex-plugin/plugin.json +39 -0
  2. package/.mcp.json +10 -0
  3. package/COPYRIGHT +6 -0
  4. package/LICENSE +661 -0
  5. package/LICENSE_TRANSITION.md +25 -0
  6. package/README.md +572 -0
  7. package/THIRD_PARTY_NOTICES.md +17 -0
  8. package/assets/logo-generated.png +0 -0
  9. package/assets/logo.svg +22 -0
  10. package/boss-control-outbox.ts +195 -0
  11. package/broker/access-credential.ts +76 -0
  12. package/broker/access-registry.ts +464 -0
  13. package/broker/audit.ts +60 -0
  14. package/broker/authorization.ts +54 -0
  15. package/broker/boss-adapter.ts +531 -0
  16. package/broker/boss-control-ledger.ts +306 -0
  17. package/broker/broker.ts +2327 -0
  18. package/broker/client.ts +1111 -0
  19. package/broker/framing.ts +87 -0
  20. package/broker/ownership.ts +61 -0
  21. package/broker/paths.ts +161 -0
  22. package/broker/spawn.ts +453 -0
  23. package/codex/app-server-client.ts +562 -0
  24. package/codex/boss-client.ts +34 -0
  25. package/codex/bridge-config.ts +282 -0
  26. package/codex/bridge-daemon.ts +913 -0
  27. package/codex/clipboard.ts +142 -0
  28. package/codex/coi.ts +802 -0
  29. package/codex/contact.ts +54 -0
  30. package/codex/mcp-protocol.ts +248 -0
  31. package/codex/runtime.ts +488 -0
  32. package/codex/server.ts +60 -0
  33. package/codex/team.ts +249 -0
  34. package/codex/tui-input.ts +174 -0
  35. package/config.ts +195 -0
  36. package/dist/bridge-daemon.mjs +3781 -0
  37. package/dist/broker.mjs +3012 -0
  38. package/dist/build-info.json +12 -0
  39. package/dist/codex-server.mjs +2890 -0
  40. package/dist/coi.mjs +4686 -0
  41. package/durable-json.ts +25 -0
  42. package/licenses/MIT-pi-intercom.txt +21 -0
  43. package/outbound-outbox.ts +116 -0
  44. package/package.json +89 -0
  45. package/protocol-v4/contract.ts +27 -0
  46. package/provider/protected-service.ts +162 -0
  47. package/provider/provider.mjs +34 -0
  48. package/scripts/build-info.mjs +58 -0
  49. package/scripts/build.mjs +55 -0
  50. package/skills/codex-intercom/SKILL.md +104 -0
  51. package/tsconfig.json +16 -0
  52. package/types.ts +225 -0
package/codex/team.ts ADDED
@@ -0,0 +1,249 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { getAgentDirPath } from "../broker/paths.ts";
4
+ import {
5
+ BOSS_PARTICIPANT_ROLES,
6
+ parseParticipantState,
7
+ parseWorkerIdentityV2,
8
+ workerIdentityFromEnvironment,
9
+ type WorkerIdentityV2,
10
+ } from "@ctliz/agent-intercom-core/boss";
11
+
12
+ export interface TeamSession {
13
+ id: string;
14
+ name?: string;
15
+ boss?: {
16
+ binding?: {
17
+ bossRunId?: unknown;
18
+ participantId?: unknown;
19
+ bindingEpoch?: unknown;
20
+ role?: unknown;
21
+ sessionId?: unknown;
22
+ state?: unknown;
23
+ };
24
+ workerIdentity?: unknown;
25
+ participantState?: unknown;
26
+ };
27
+ }
28
+
29
+ interface StoredWorker {
30
+ id?: unknown;
31
+ runId?: unknown;
32
+ workerIncarnationId?: unknown;
33
+ workerGeneration?: unknown;
34
+ bossRunId?: unknown;
35
+ participantId?: unknown;
36
+ bindingEpoch?: unknown;
37
+ harness?: unknown;
38
+ role?: unknown;
39
+ state?: unknown;
40
+ owned?: unknown;
41
+ managerSessionId?: unknown;
42
+ intercomTarget?: unknown;
43
+ canonicalIdentity?: WorkerIdentityV2;
44
+ }
45
+
46
+ export interface TeamMember { id: string; target: string; harness?: string; role?: string; state?: string; connected: boolean; }
47
+ export interface IntercomTeam { teamId?: string; self: { id: string; workerId?: string; isManager: boolean }; manager?: { target: string; connected: boolean }; coworkers: TeamMember[]; }
48
+
49
+ const LEGACY_LIVE_STATES = new Set(["provisioning", "running", "idle", "needs_attention", "stopping"]);
50
+ const CANONICAL_LIVE_STATES = new Set(["provisioning", "registering", "ready", "working", "waiting", "paused", "stalled", "blocked", "unreachable"]);
51
+ const stringValue = (value: unknown): string | undefined => typeof value === "string" && value.trim() ? value.trim() : undefined;
52
+ const connectedTo = (sessions: TeamSession[], target: string): boolean => {
53
+ const normalized = target.toLowerCase();
54
+ return sessions.some((session) => session.id === target || session.name?.toLowerCase() === normalized);
55
+ };
56
+
57
+ function bossIdentityFromEnvironment(env: NodeJS.ProcessEnv): WorkerIdentityV2 | undefined {
58
+ const bossKeys = ["AGENT_INTERCOM_BOSS_RUN_ID", "AGENT_INTERCOM_PARTICIPANT_ID", "AGENT_INTERCOM_BINDING_EPOCH"] as const;
59
+ if (!bossKeys.some((key) => env[key] !== undefined)) return undefined;
60
+ const identity = workerIdentityFromEnvironment(env);
61
+ if (!("bossRunId" in identity)) throw new Error("Incomplete Boss worker identity cannot discover a team");
62
+ return identity;
63
+ }
64
+
65
+ function canonicalWorker(value: unknown): StoredWorker {
66
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("worker must be an object");
67
+ const worker = value as StoredWorker;
68
+ const identity = parseWorkerIdentityV2({
69
+ version: "orc.worker-identity.v2",
70
+ workerId: worker.id,
71
+ workerIncarnationId: worker.workerIncarnationId,
72
+ workerGeneration: worker.workerGeneration,
73
+ ...(worker.bossRunId === undefined ? {} : { bossRunId: worker.bossRunId }),
74
+ ...(worker.participantId === undefined ? {} : { participantId: worker.participantId }),
75
+ ...(worker.bindingEpoch === undefined ? {} : { bindingEpoch: worker.bindingEpoch }),
76
+ });
77
+ parseParticipantState(worker.state, "$.worker.state");
78
+ if (typeof worker.role !== "string" || !BOSS_PARTICIPANT_ROLES.includes(worker.role as never)) {
79
+ throw new Error("worker role is not canonical");
80
+ }
81
+ if (worker.owned !== true || !stringValue(worker.managerSessionId) || !stringValue(worker.intercomTarget)) {
82
+ throw new Error("canonical worker ownership routing is incomplete");
83
+ }
84
+ return { ...worker, canonicalIdentity: identity };
85
+ }
86
+
87
+ function exactBossRosterSession(sessions: TeamSession[], worker: StoredWorker): TeamSession | undefined {
88
+ const identity = worker.canonicalIdentity;
89
+ const target = stringValue(worker.intercomTarget);
90
+ const role = stringValue(worker.role);
91
+ const state = stringValue(worker.state);
92
+ if (!identity || !("bossRunId" in identity) || !target || !role || !state) return undefined;
93
+ const matches = sessions.filter((candidate) => candidate.id === target);
94
+ if (matches.length !== 1) return undefined;
95
+ const [session] = matches;
96
+ if (!session?.boss?.binding || session.boss.workerIdentity === undefined || session.boss.participantState === undefined) return undefined;
97
+ try {
98
+ const sessionIdentity = parseWorkerIdentityV2(session.boss.workerIdentity);
99
+ const sessionState = parseParticipantState(session.boss.participantState, "$.session.boss.participantState");
100
+ const binding = session.boss.binding;
101
+ return (
102
+ "bossRunId" in sessionIdentity
103
+ && session.id === target
104
+ && binding.sessionId === session.id
105
+ && binding.state === "active"
106
+ && binding.bossRunId === identity.bossRunId
107
+ && binding.participantId === identity.participantId
108
+ && binding.bindingEpoch === identity.bindingEpoch
109
+ && binding.role === role
110
+ && sessionIdentity.workerId === identity.workerId
111
+ && sessionIdentity.workerIncarnationId === identity.workerIncarnationId
112
+ && sessionIdentity.workerGeneration === identity.workerGeneration
113
+ && sessionIdentity.bossRunId === identity.bossRunId
114
+ && sessionIdentity.participantId === identity.participantId
115
+ && sessionIdentity.bindingEpoch === identity.bindingEpoch
116
+ && sessionState === state
117
+ ) ? session : undefined;
118
+ } catch {
119
+ return undefined;
120
+ }
121
+ }
122
+
123
+ async function readWorkers(agentDir: string): Promise<{ version: 1 | 2; workers: StoredWorker[] }> {
124
+ try {
125
+ const parsed: unknown = JSON.parse(await readFile(join(agentDir, "intercom", "orchestrator", "workers.json"), "utf8"));
126
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("worker snapshot must be an object");
127
+ const snapshot = parsed as { version?: unknown; workers?: unknown };
128
+ if ((snapshot.version !== 1 && snapshot.version !== 2) || !Array.isArray(snapshot.workers)) throw new Error("unsupported worker snapshot version");
129
+ if (snapshot.version === 1) return { version: 1, workers: snapshot.workers as StoredWorker[] };
130
+ return { version: 2, workers: snapshot.workers.map(canonicalWorker) };
131
+ } catch {
132
+ return { version: 1, workers: [] };
133
+ }
134
+ }
135
+
136
+ export async function resolveIntercomTeam(input: { selfId: string; sessions: TeamSession[]; env?: NodeJS.ProcessEnv; agentDir?: string }): Promise<IntercomTeam> {
137
+ const env = input.env ?? process.env;
138
+ const snapshot = await readWorkers(input.agentDir ?? getAgentDirPath());
139
+ const workers = snapshot.workers;
140
+ const workerId = stringValue(env.AGENT_INTERCOM_WORKER_ID);
141
+ const bossIdentity = bossIdentityFromEnvironment(env);
142
+ const runId = stringValue(env.AGENT_INTERCOM_RUN_ID);
143
+ const currentMatches = workerId ? workers.filter((worker) => (
144
+ stringValue(worker.id) === workerId
145
+ && (bossIdentity === undefined
146
+ ? (!runId || stringValue(worker.runId) === runId)
147
+ : snapshot.version === 2
148
+ && worker.canonicalIdentity?.workerId === bossIdentity.workerId
149
+ && worker.canonicalIdentity.workerIncarnationId === bossIdentity.workerIncarnationId
150
+ && worker.canonicalIdentity.workerGeneration === bossIdentity.workerGeneration
151
+ && "bossRunId" in worker.canonicalIdentity
152
+ && "bossRunId" in bossIdentity
153
+ && worker.canonicalIdentity.bossRunId === bossIdentity.bossRunId
154
+ && worker.canonicalIdentity.participantId === bossIdentity.participantId
155
+ && worker.canonicalIdentity.bindingEpoch === bossIdentity.bindingEpoch)
156
+ )) : [];
157
+ const current = bossIdentity === undefined ? currentMatches[0] : currentMatches.length === 1 ? currentMatches[0] : undefined;
158
+ const currentTarget = stringValue(current?.intercomTarget);
159
+ const exactCurrentProjection = current !== undefined
160
+ && currentTarget === input.selfId
161
+ && workers.filter((worker) => stringValue(worker.id) === workerId).length === 1
162
+ && workers.filter((worker) => stringValue(worker.intercomTarget) === currentTarget).length === 1
163
+ && exactBossRosterSession(input.sessions, current) !== undefined;
164
+
165
+ // Privileged Boss discovery is rooted in one exact current worker/session
166
+ // projection. A substituted self ID, ambiguous ID/target, or stale binding
167
+ // never unlocks a roster assembled from ambient same-run records.
168
+ if (bossIdentity !== undefined && !exactCurrentProjection) {
169
+ return { self: { id: input.selfId, ...(workerId ? { workerId } : {}), isManager: false }, coworkers: [] };
170
+ }
171
+
172
+ const managerTarget = stringValue(current?.managerSessionId)
173
+ ?? (bossIdentity === undefined ? stringValue(env.AGENT_INTERCOM_MANAGER_TARGET) ?? stringValue(env.AGENT_INTERCOM_MANAGER_SESSION_ID) : undefined);
174
+ const teamId = managerTarget ?? input.selfId;
175
+ const currentRole = stringValue(current?.role);
176
+ const canDiscoverOwnedRoster = bossIdentity === undefined || currentRole === "manager" || currentRole === "controller";
177
+ const coworkers = (canDiscoverOwnedRoster ? workers : []).filter((worker) => worker.owned === true)
178
+ .filter((worker) => bossIdentity === undefined || (
179
+ snapshot.version === 2
180
+ && worker.canonicalIdentity !== undefined
181
+ && "bossRunId" in worker.canonicalIdentity
182
+ && "bossRunId" in bossIdentity
183
+ && worker.canonicalIdentity.bossRunId === bossIdentity.bossRunId
184
+ ))
185
+ .filter((worker) => stringValue(worker.managerSessionId) === teamId)
186
+ .filter((worker) => stringValue(worker.intercomTarget) !== managerTarget)
187
+ .filter((worker) => (snapshot.version === 2 ? CANONICAL_LIVE_STATES : LEGACY_LIVE_STATES).has(stringValue(worker.state) ?? ""))
188
+ .filter((worker) => stringValue(worker.id) !== workerId)
189
+ .map((worker): TeamMember | undefined => {
190
+ const id = stringValue(worker.id);
191
+ if (!id) return undefined;
192
+ const target = stringValue(worker.intercomTarget) ?? id;
193
+ const connected = bossIdentity === undefined
194
+ ? connectedTo(input.sessions, target)
195
+ : exactBossRosterSession(input.sessions, worker) !== undefined;
196
+ if (!connected) return undefined;
197
+ return {
198
+ id,
199
+ target,
200
+ ...(stringValue(worker.harness) ? { harness: stringValue(worker.harness) } : {}),
201
+ ...(stringValue(worker.role) ? { role: stringValue(worker.role) } : {}),
202
+ ...(stringValue(worker.state) ? { state: stringValue(worker.state) } : {}),
203
+ connected,
204
+ };
205
+ }).filter((member): member is TeamMember => Boolean(member));
206
+
207
+ const managerWorker = managerTarget === undefined
208
+ ? undefined
209
+ : workers.find((worker) => (
210
+ stringValue(worker.intercomTarget) === managerTarget
211
+ && (bossIdentity === undefined || (
212
+ snapshot.version === 2
213
+ && stringValue(worker.role) === "manager"
214
+ && worker.canonicalIdentity !== undefined
215
+ && "bossRunId" in worker.canonicalIdentity
216
+ && "bossRunId" in bossIdentity
217
+ && worker.canonicalIdentity.bossRunId === bossIdentity.bossRunId
218
+ ))
219
+ ));
220
+ const managerConnected = managerTarget === undefined
221
+ ? true
222
+ : bossIdentity === undefined
223
+ ? connectedTo(input.sessions, managerTarget)
224
+ : managerWorker !== undefined && exactBossRosterSession(input.sessions, managerWorker) !== undefined;
225
+ return {
226
+ teamId,
227
+ self: { id: input.selfId, ...(workerId ? { workerId } : {}), isManager: bossIdentity === undefined && !managerTarget },
228
+ ...(managerTarget
229
+ ? { manager: { target: managerTarget, connected: managerConnected } }
230
+ : bossIdentity === undefined ? { manager: { target: input.selfId, connected: true } } : {}),
231
+ coworkers,
232
+ };
233
+ }
234
+
235
+ export function formatIntercomTeam(team: IntercomTeam): string {
236
+ const lines = [
237
+ `Manager: ${team.manager ? `${team.manager.target} [${team.manager.connected ? "connected" : "not connected"}]` : "unknown"}`,
238
+ `You: ${team.self.workerId ?? team.self.id}${team.self.isManager ? " [manager]" : ""}`,
239
+ ];
240
+ if (!team.coworkers.length) lines.push("Coworkers: none");
241
+ else {
242
+ lines.push("Coworkers:");
243
+ for (const coworker of team.coworkers) {
244
+ const metadata = [coworker.harness, coworker.role, coworker.state].filter(Boolean).join(", ");
245
+ lines.push(`- ${coworker.id} target=${coworker.target}${metadata ? ` (${metadata})` : ""} [${coworker.connected ? "connected" : "not connected"}]`);
246
+ }
247
+ }
248
+ return lines.join("\n");
249
+ }
@@ -0,0 +1,174 @@
1
+ import { StringDecoder } from "node:string_decoder";
2
+
3
+ const ESC = "\x1b";
4
+ const ALT_MODIFIER_BIT = 0b10;
5
+ const LOCK_MODIFIER_BITS = 0b1100_0000;
6
+ const DISALLOWED_MODIFIER_BITS = 0b0011_1101;
7
+ const KEY_I = 105;
8
+ const KEY_M = 109;
9
+
10
+ export interface FilteredTuiInput {
11
+ forwarded: string;
12
+ pending: string;
13
+ altICount: number;
14
+ altMCount: number;
15
+ }
16
+
17
+ interface ShortcutMatch {
18
+ consume: boolean;
19
+ trigger: boolean;
20
+ }
21
+
22
+ function parseNumber(value: string | undefined): number | null {
23
+ if (!value || !/^\d+$/.test(value)) return null;
24
+ const parsed = Number(value);
25
+ return Number.isSafeInteger(parsed) ? parsed : null;
26
+ }
27
+
28
+ function isAltOnlyModifier(encodedModifier: number): boolean {
29
+ const bits = encodedModifier - 1;
30
+ if (bits < 0 || (bits & ALT_MODIFIER_BIT) === 0) return false;
31
+ return (bits & DISALLOWED_MODIFIER_BITS) === 0
32
+ && (bits & ~(ALT_MODIFIER_BIT | LOCK_MODIFIER_BITS)) === 0;
33
+ }
34
+
35
+ function matchKittyAltKey(sequence: string, key: number): ShortcutMatch {
36
+ if (!sequence.endsWith("u")) return { consume: false, trigger: false };
37
+ const params = sequence.slice(2, -1).split(";");
38
+ if (params.length < 2) return { consume: false, trigger: false };
39
+
40
+ const keyParts = params[0].split(":");
41
+ const primaryKey = parseNumber(keyParts[0]);
42
+ const baseLayoutKey = parseNumber(keyParts[2]);
43
+ const modifierParts = params[1].split(":");
44
+ const modifier = parseNumber(modifierParts[0]);
45
+ const eventType = modifierParts[1] === undefined ? 1 : parseNumber(modifierParts[1]);
46
+ if (modifier === null || eventType === null || !isAltOnlyModifier(modifier)) {
47
+ return { consume: false, trigger: false };
48
+ }
49
+
50
+ if (primaryKey !== key && baseLayoutKey !== key) {
51
+ return { consume: false, trigger: false };
52
+ }
53
+
54
+ // Consume repeat/release events for the shortcut, but trigger only on press.
55
+ return { consume: true, trigger: eventType === 1 };
56
+ }
57
+
58
+ function matchModifyOtherKeysAltKey(sequence: string, expectedKey: number): ShortcutMatch {
59
+ if (!sequence.endsWith("~")) return { consume: false, trigger: false };
60
+ const params = sequence.slice(2, -1).split(";");
61
+ if (params.length !== 3 || params[0] !== "27") return { consume: false, trigger: false };
62
+ const modifier = parseNumber(params[1]);
63
+ const key = parseNumber(params[2]);
64
+ const matches = modifier !== null && key === expectedKey && isAltOnlyModifier(modifier);
65
+ return { consume: matches, trigger: matches };
66
+ }
67
+
68
+ function matchAltKeySequence(sequence: string, key: number): ShortcutMatch {
69
+ if (sequence === `${ESC}${String.fromCharCode(key)}`) return { consume: true, trigger: true };
70
+ if (!sequence.startsWith(`${ESC}[`)) return { consume: false, trigger: false };
71
+ if (sequence.endsWith("u")) return matchKittyAltKey(sequence, key);
72
+ if (sequence.endsWith("~")) return matchModifyOtherKeysAltKey(sequence, key);
73
+ return { consume: false, trigger: false };
74
+ }
75
+
76
+ function findCsiEnd(source: string, start: number): number {
77
+ for (let index = start + 2; index < source.length; index += 1) {
78
+ const code = source.charCodeAt(index);
79
+ if (code >= 0x40 && code <= 0x7e) return index;
80
+ }
81
+ return -1;
82
+ }
83
+
84
+ /**
85
+ * Remove semantic Alt+I key events while preserving all other terminal input.
86
+ * `pending` carries a partial escape sequence across input chunks.
87
+ */
88
+ export function filterAltIInput(input: string, pending = ""): FilteredTuiInput {
89
+ const source = pending + input;
90
+ let forwarded = "";
91
+ let altICount = 0;
92
+ let altMCount = 0;
93
+ let index = 0;
94
+
95
+ while (index < source.length) {
96
+ if (source[index] !== ESC) {
97
+ forwarded += source[index];
98
+ index += 1;
99
+ continue;
100
+ }
101
+
102
+ if (index + 1 >= source.length) {
103
+ return { forwarded, pending: source.slice(index), altICount, altMCount };
104
+ }
105
+
106
+ if (source[index + 1] === "i") {
107
+ altICount += 1;
108
+ index += 2;
109
+ continue;
110
+ }
111
+ if (source[index + 1] === "m") {
112
+ altMCount += 1;
113
+ index += 2;
114
+ continue;
115
+ }
116
+
117
+ if (source[index + 1] !== "[") {
118
+ forwarded += source[index];
119
+ index += 1;
120
+ continue;
121
+ }
122
+
123
+ const end = findCsiEnd(source, index);
124
+ if (end === -1) {
125
+ return { forwarded, pending: source.slice(index), altICount, altMCount };
126
+ }
127
+
128
+ const sequence = source.slice(index, end + 1);
129
+ const altI = matchAltKeySequence(sequence, KEY_I);
130
+ const altM = matchAltKeySequence(sequence, KEY_M);
131
+ if (altI.consume || altM.consume) {
132
+ if (altI.trigger) altICount += 1;
133
+ if (altM.trigger) altMCount += 1;
134
+ } else {
135
+ forwarded += sequence;
136
+ }
137
+ index = end + 1;
138
+ }
139
+
140
+ return { forwarded, pending: "", altICount, altMCount };
141
+ }
142
+
143
+ /** UTF-8-safe stateful wrapper for terminal chunks and partial escape sequences. */
144
+ export class TuiInputDecoder {
145
+ private readonly utf8 = new StringDecoder("utf8");
146
+ private pending = "";
147
+
148
+ write(chunk: Buffer | string): Omit<FilteredTuiInput, "pending"> {
149
+ const text = typeof chunk === "string" ? chunk : this.utf8.write(chunk);
150
+ const filtered = filterAltIInput(text, this.pending);
151
+ this.pending = filtered.pending;
152
+ return { forwarded: filtered.forwarded, altICount: filtered.altICount, altMCount: filtered.altMCount };
153
+ }
154
+
155
+ hasPendingEscape(): boolean {
156
+ return this.pending.length > 0;
157
+ }
158
+
159
+ flushPendingEscape(): string {
160
+ const pending = this.pending;
161
+ this.pending = "";
162
+ return pending;
163
+ }
164
+
165
+ end(): Omit<FilteredTuiInput, "pending"> {
166
+ const filtered = filterAltIInput(this.utf8.end(), this.pending);
167
+ this.pending = "";
168
+ return {
169
+ forwarded: filtered.forwarded + filtered.pending,
170
+ altICount: filtered.altICount,
171
+ altMCount: filtered.altMCount,
172
+ };
173
+ }
174
+ }
package/config.ts ADDED
@@ -0,0 +1,195 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { join, resolve } from "path";
3
+ import { homedir } from "os";
4
+
5
+ export const DEFAULT_ASK_TIMEOUT_MS = 45 * 1000;
6
+ export const MAX_ASK_TIMEOUT_MS = 120 * 1000;
7
+
8
+ export function validateAskTimeoutMs(value: unknown, name = "timeout_ms"): number {
9
+ if (!Number.isSafeInteger(value) || typeof value !== "number" || value <= 0) {
10
+ throw new Error(`${name} must be a positive integer number of milliseconds`);
11
+ }
12
+ if (value > MAX_ASK_TIMEOUT_MS) {
13
+ throw new Error(`${name} must be ${MAX_ASK_TIMEOUT_MS} ms or less; use intercom_send plus intercom_pending for longer-running work`);
14
+ }
15
+ return value;
16
+ }
17
+
18
+ export function getAskTimeoutMs(): number {
19
+ const raw = process.env.PI_INTERCOM_ASK_TIMEOUT_MS;
20
+ if (raw === undefined || raw.trim() === "") {
21
+ return DEFAULT_ASK_TIMEOUT_MS;
22
+ }
23
+
24
+ const value = Number(raw);
25
+ return validateAskTimeoutMs(value, "PI_INTERCOM_ASK_TIMEOUT_MS");
26
+ }
27
+
28
+ export interface InboundForkHandlersConfig {
29
+ /** Route inbound intercom messages to background fork handlers (default: true) */
30
+ enabled: boolean;
31
+
32
+ /** When to fork: automatically while parent is busy/queued, only while busy, or for all inbound messages */
33
+ when: "auto" | "busy" | "always";
34
+
35
+ /** Parent notification policy for launched handlers */
36
+ notify: "ack-and-summary" | "summary" | "none";
37
+
38
+ /** Optional Pi executable override for handler launch */
39
+ piCommand?: string;
40
+
41
+ /** Trigger a parent turn when the handler summary arrives (default: auto; ignored when notify is "none") */
42
+ triggerParentOnSummary: boolean | "auto";
43
+ }
44
+
45
+ export interface IntercomConfig {
46
+ /** Broker command used to spawn the broker process (e.g. "npx" or "bun") */
47
+ brokerCommand: string;
48
+
49
+ /** Arguments passed to the broker command before the broker script path */
50
+ brokerArgs: string[];
51
+
52
+ /** Require confirmation before non-reply sends from interactive sessions */
53
+ confirmSend: boolean;
54
+
55
+ /** Optional custom status suffix shown after automatic lifecycle status */
56
+ status?: string;
57
+
58
+ /** Enable/disable intercom (default: true) */
59
+ enabled: boolean;
60
+
61
+ /** Show reply hint in incoming messages (default: true) */
62
+ replyHint: boolean;
63
+
64
+ /** Optional inbound background fork-handler routing */
65
+ inboundForkHandlers: InboundForkHandlersConfig;
66
+ }
67
+
68
+ export function getConfigPath(): string {
69
+ const agentDir = process.env.PI_CODING_AGENT_DIR
70
+ ? resolve(process.env.PI_CODING_AGENT_DIR)
71
+ : join(homedir(), ".pi", "agent");
72
+ return join(agentDir, "intercom", "config.json");
73
+ }
74
+
75
+ const defaults: IntercomConfig = {
76
+ brokerCommand: "npx",
77
+ brokerArgs: ["--no-install", "tsx"],
78
+ confirmSend: false,
79
+ enabled: true,
80
+ replyHint: true,
81
+ inboundForkHandlers: {
82
+ enabled: true,
83
+ when: "auto",
84
+ notify: "summary",
85
+ triggerParentOnSummary: "auto",
86
+ },
87
+ };
88
+
89
+ export function loadConfig(): IntercomConfig {
90
+ const configPath = getConfigPath();
91
+ if (!existsSync(configPath)) {
92
+ return { ...defaults };
93
+ }
94
+
95
+ try {
96
+ const raw = readFileSync(configPath, "utf-8");
97
+ const parsed: unknown = JSON.parse(raw);
98
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
99
+ throw new Error("Config must be a JSON object");
100
+ }
101
+
102
+ const parsedConfig = parsed as Record<string, unknown>;
103
+ const config: IntercomConfig = { ...defaults };
104
+
105
+ if (Object.hasOwn(parsedConfig, "brokerCommand")) {
106
+ if (typeof parsedConfig.brokerCommand !== "string") {
107
+ throw new Error(`"brokerCommand" must be a string`);
108
+ }
109
+ const brokerCommand = parsedConfig.brokerCommand.trim();
110
+ if (!brokerCommand) {
111
+ throw new Error(`"brokerCommand" must not be empty`);
112
+ }
113
+ config.brokerCommand = brokerCommand;
114
+ }
115
+
116
+ if (Object.hasOwn(parsedConfig, "brokerArgs")) {
117
+ if (!Array.isArray(parsedConfig.brokerArgs)) {
118
+ throw new Error(`"brokerArgs" must be an array`);
119
+ }
120
+ const brokerArgs: string[] = [];
121
+ for (const arg of parsedConfig.brokerArgs) {
122
+ if (typeof arg !== "string") {
123
+ throw new Error(`"brokerArgs" items must be strings`);
124
+ }
125
+ brokerArgs.push(arg);
126
+ }
127
+ config.brokerArgs = brokerArgs;
128
+ }
129
+
130
+ if (Object.hasOwn(parsedConfig, "confirmSend")) {
131
+ if (typeof parsedConfig.confirmSend !== "boolean") {
132
+ throw new Error(`"confirmSend" must be a boolean`);
133
+ }
134
+ config.confirmSend = parsedConfig.confirmSend;
135
+ }
136
+
137
+ if (Object.hasOwn(parsedConfig, "enabled")) {
138
+ if (typeof parsedConfig.enabled !== "boolean") {
139
+ throw new Error(`"enabled" must be a boolean`);
140
+ }
141
+ config.enabled = parsedConfig.enabled;
142
+ }
143
+
144
+ if (Object.hasOwn(parsedConfig, "replyHint")) {
145
+ if (typeof parsedConfig.replyHint !== "boolean") {
146
+ throw new Error(`"replyHint" must be a boolean`);
147
+ }
148
+ config.replyHint = parsedConfig.replyHint;
149
+ }
150
+
151
+ if (Object.hasOwn(parsedConfig, "status")) {
152
+ if (typeof parsedConfig.status !== "string") {
153
+ throw new Error(`"status" must be a string`);
154
+ }
155
+ config.status = parsedConfig.status;
156
+ }
157
+
158
+ if (Object.hasOwn(parsedConfig, "inboundForkHandlers")) {
159
+ if (typeof parsedConfig.inboundForkHandlers !== "object" || parsedConfig.inboundForkHandlers === null || Array.isArray(parsedConfig.inboundForkHandlers)) {
160
+ throw new Error(`"inboundForkHandlers" must be an object`);
161
+ }
162
+ const forkConfig = parsedConfig.inboundForkHandlers as Record<string, unknown>;
163
+ config.inboundForkHandlers = { ...defaults.inboundForkHandlers };
164
+ if (Object.hasOwn(forkConfig, "enabled")) {
165
+ if (typeof forkConfig.enabled !== "boolean") throw new Error(`"inboundForkHandlers.enabled" must be a boolean`);
166
+ config.inboundForkHandlers.enabled = forkConfig.enabled;
167
+ }
168
+ if (Object.hasOwn(forkConfig, "when")) {
169
+ if (forkConfig.when !== "auto" && forkConfig.when !== "busy" && forkConfig.when !== "always") throw new Error(`"inboundForkHandlers.when" must be "auto", "busy", or "always"`);
170
+ config.inboundForkHandlers.when = forkConfig.when;
171
+ }
172
+ if (Object.hasOwn(forkConfig, "notify")) {
173
+ if (forkConfig.notify !== "ack-and-summary" && forkConfig.notify !== "summary" && forkConfig.notify !== "none") throw new Error(`"inboundForkHandlers.notify" must be "ack-and-summary", "summary", or "none"`);
174
+ config.inboundForkHandlers.notify = forkConfig.notify;
175
+ }
176
+ if (Object.hasOwn(forkConfig, "piCommand")) {
177
+ if (typeof forkConfig.piCommand !== "string") throw new Error(`"inboundForkHandlers.piCommand" must be a string`);
178
+ const piCommand = forkConfig.piCommand.trim();
179
+ if (piCommand) config.inboundForkHandlers.piCommand = piCommand;
180
+ }
181
+ if (Object.hasOwn(forkConfig, "triggerParentOnSummary")) {
182
+ const triggerParentOnSummary = forkConfig.triggerParentOnSummary;
183
+ if (typeof triggerParentOnSummary !== "boolean" && triggerParentOnSummary !== "auto") {
184
+ throw new Error(`"inboundForkHandlers.triggerParentOnSummary" must be a boolean or "auto"`);
185
+ }
186
+ config.inboundForkHandlers.triggerParentOnSummary = triggerParentOnSummary;
187
+ }
188
+ }
189
+
190
+ return config;
191
+ } catch (error) {
192
+ console.error(`Failed to load intercom config at ${configPath}:`, error);
193
+ return { ...defaults };
194
+ }
195
+ }