@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.
- package/.codex-plugin/plugin.json +39 -0
- package/.mcp.json +10 -0
- package/COPYRIGHT +6 -0
- package/LICENSE +661 -0
- package/LICENSE_TRANSITION.md +25 -0
- package/README.md +572 -0
- package/THIRD_PARTY_NOTICES.md +17 -0
- package/assets/logo-generated.png +0 -0
- package/assets/logo.svg +22 -0
- package/boss-control-outbox.ts +195 -0
- package/broker/access-credential.ts +76 -0
- package/broker/access-registry.ts +464 -0
- package/broker/audit.ts +60 -0
- package/broker/authorization.ts +54 -0
- package/broker/boss-adapter.ts +531 -0
- package/broker/boss-control-ledger.ts +306 -0
- package/broker/broker.ts +2327 -0
- package/broker/client.ts +1111 -0
- package/broker/framing.ts +87 -0
- package/broker/ownership.ts +61 -0
- package/broker/paths.ts +161 -0
- package/broker/spawn.ts +453 -0
- package/codex/app-server-client.ts +562 -0
- package/codex/boss-client.ts +34 -0
- package/codex/bridge-config.ts +282 -0
- package/codex/bridge-daemon.ts +913 -0
- package/codex/clipboard.ts +142 -0
- package/codex/coi.ts +802 -0
- package/codex/contact.ts +54 -0
- package/codex/mcp-protocol.ts +248 -0
- package/codex/runtime.ts +488 -0
- package/codex/server.ts +60 -0
- package/codex/team.ts +249 -0
- package/codex/tui-input.ts +174 -0
- package/config.ts +195 -0
- package/dist/bridge-daemon.mjs +3781 -0
- package/dist/broker.mjs +3012 -0
- package/dist/build-info.json +12 -0
- package/dist/codex-server.mjs +2890 -0
- package/dist/coi.mjs +4686 -0
- package/durable-json.ts +25 -0
- package/licenses/MIT-pi-intercom.txt +21 -0
- package/outbound-outbox.ts +116 -0
- package/package.json +89 -0
- package/protocol-v4/contract.ts +27 -0
- package/provider/protected-service.ts +162 -0
- package/provider/provider.mjs +34 -0
- package/scripts/build-info.mjs +58 -0
- package/scripts/build.mjs +55 -0
- package/skills/codex-intercom/SKILL.md +104 -0
- package/tsconfig.json +16 -0
- 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
|
+
}
|