@ctliz/agent-intercom-pi 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/COPYRIGHT +6 -0
- package/LICENSE +661 -0
- package/LICENSE_TRANSITION.md +26 -0
- package/README.md +693 -0
- package/THIRD_PARTY_NOTICES.md +17 -0
- package/banner.png +0 -0
- package/boss-team-scope.ts +241 -0
- package/broker/access-credential.ts +76 -0
- package/broker/access-registry.ts +460 -0
- package/broker/audit.ts +60 -0
- package/broker/authorization.ts +140 -0
- package/broker/boss-adapter.ts +383 -0
- package/broker/broker-impl.ts +2019 -0
- package/broker/broker.ts +7 -0
- package/broker/client.ts +932 -0
- package/broker/framing.ts +87 -0
- package/broker/ownership.ts +61 -0
- package/broker/paths.ts +161 -0
- package/broker/protected-service.ts +158 -0
- package/broker/registration.ts +173 -0
- package/broker/spawn.ts +448 -0
- package/config.ts +183 -0
- package/control.ts +150 -0
- package/durable-json.ts +25 -0
- package/inbound-inbox.ts +296 -0
- package/index.ts +2826 -0
- package/licenses/MIT-pi-intercom.txt +21 -0
- package/outbound-outbox.ts +116 -0
- package/package.json +72 -0
- package/protocol-v4/contract.ts +39 -0
- package/provider/provider.mjs +34 -0
- package/reply-tracker.ts +231 -0
- package/skills/pi-intercom/SKILL.md +537 -0
- package/team.ts +192 -0
- package/types.ts +183 -0
- package/ui/compose.ts +229 -0
- package/ui/inline-message.ts +126 -0
- package/ui/session-identity.ts +55 -0
- package/ui/session-list.ts +205 -0
- package/ui/timestamps.ts +50 -0
package/team.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDirPath } from "./broker/paths.ts";
|
|
4
|
+
import { bossSelfSessionError, resolveBossLiveSession, type BossTeamScope } from "./boss-team-scope.ts";
|
|
5
|
+
|
|
6
|
+
export interface TeamSession {
|
|
7
|
+
id: string;
|
|
8
|
+
name?: string;
|
|
9
|
+
origin?: "local" | "remote";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface StoredWorker {
|
|
13
|
+
id?: unknown;
|
|
14
|
+
runId?: unknown;
|
|
15
|
+
harness?: unknown;
|
|
16
|
+
role?: unknown;
|
|
17
|
+
state?: unknown;
|
|
18
|
+
owned?: unknown;
|
|
19
|
+
managerSessionId?: unknown;
|
|
20
|
+
intercomTarget?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TeamMember {
|
|
24
|
+
id: string;
|
|
25
|
+
target: string;
|
|
26
|
+
harness?: string;
|
|
27
|
+
role?: string;
|
|
28
|
+
state?: string;
|
|
29
|
+
connected: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface IntercomTeam {
|
|
33
|
+
teamId?: string;
|
|
34
|
+
self: { id: string; workerId?: string; isManager: boolean };
|
|
35
|
+
manager?: { target: string; connected: boolean };
|
|
36
|
+
controller?: { target: string; connected: boolean };
|
|
37
|
+
coworkers: TeamMember[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Authorizes a read-only local inbox lookup using exact orchestrator ownership. */
|
|
41
|
+
export function resolveManagedInboxSession(input: {
|
|
42
|
+
team: IntercomTeam;
|
|
43
|
+
sessions: TeamSession[];
|
|
44
|
+
requestedSession: string;
|
|
45
|
+
}): TeamSession {
|
|
46
|
+
if (!input.team.self.isManager) {
|
|
47
|
+
throw new Error("Only a manager may inspect another session's pending-ask inbox");
|
|
48
|
+
}
|
|
49
|
+
const member = input.team.coworkers.find((entry) => entry.target === input.requestedSession);
|
|
50
|
+
if (!member) {
|
|
51
|
+
throw new Error(`Pending-ask inbox access denied for "${input.requestedSession}"; select an owned coworker target returned by intercom_team`);
|
|
52
|
+
}
|
|
53
|
+
const liveSession = input.sessions.find((session) => session.id === input.requestedSession);
|
|
54
|
+
if (!liveSession) {
|
|
55
|
+
throw new Error(`Pending-ask inbox access denied for "${input.requestedSession}"; the owned coworker target must equal an exact connected stable session ID`);
|
|
56
|
+
}
|
|
57
|
+
if (liveSession.origin === "remote") {
|
|
58
|
+
throw new Error(`Pending-ask inbox "${input.requestedSession}" is remote and cannot be read from this host`);
|
|
59
|
+
}
|
|
60
|
+
return liveSession;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const LIVE_STATES = new Set(["provisioning", "running", "idle", "needs_attention", "stopping"]);
|
|
64
|
+
|
|
65
|
+
function stringValue(value: unknown): string | undefined {
|
|
66
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function connectedTo(sessions: TeamSession[], target: string): boolean {
|
|
70
|
+
const normalized = target.toLowerCase();
|
|
71
|
+
return sessions.some((session) => session.id === target || session.name?.toLowerCase() === normalized);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function readWorkers(agentDir: string): Promise<StoredWorker[]> {
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(await readFile(join(agentDir, "intercom", "orchestrator", "workers.json"), "utf8")) as { workers?: unknown };
|
|
77
|
+
return Array.isArray(parsed.workers) ? parsed.workers as StoredWorker[] : [];
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function resolveIntercomTeam(input: {
|
|
84
|
+
selfId: string;
|
|
85
|
+
sessions: TeamSession[];
|
|
86
|
+
env?: NodeJS.ProcessEnv;
|
|
87
|
+
agentDir?: string;
|
|
88
|
+
}): Promise<IntercomTeam> {
|
|
89
|
+
const env = input.env ?? process.env;
|
|
90
|
+
const workers = await readWorkers(input.agentDir ?? getAgentDirPath());
|
|
91
|
+
const workerId = stringValue(env.AGENT_INTERCOM_WORKER_ID);
|
|
92
|
+
const runId = stringValue(env.AGENT_INTERCOM_RUN_ID);
|
|
93
|
+
const current = workerId
|
|
94
|
+
? workers.find((worker) => stringValue(worker.id) === workerId && (!runId || stringValue(worker.runId) === runId))
|
|
95
|
+
: undefined;
|
|
96
|
+
const managerTarget = stringValue(current?.managerSessionId) ?? stringValue(env.AGENT_INTERCOM_MANAGER_TARGET) ?? stringValue(env.AGENT_INTERCOM_MANAGER_SESSION_ID);
|
|
97
|
+
const teamId = managerTarget ?? input.selfId;
|
|
98
|
+
const isManager = !managerTarget;
|
|
99
|
+
const coworkers = workers
|
|
100
|
+
.filter((worker) => worker.owned === true)
|
|
101
|
+
.filter((worker) => stringValue(worker.managerSessionId) === teamId)
|
|
102
|
+
.filter((worker) => LIVE_STATES.has(stringValue(worker.state) ?? ""))
|
|
103
|
+
.filter((worker) => stringValue(worker.id) !== workerId)
|
|
104
|
+
.map((worker): TeamMember | undefined => {
|
|
105
|
+
const id = stringValue(worker.id);
|
|
106
|
+
if (!id) return undefined;
|
|
107
|
+
const target = stringValue(worker.intercomTarget) ?? id;
|
|
108
|
+
return {
|
|
109
|
+
id,
|
|
110
|
+
target,
|
|
111
|
+
...(stringValue(worker.harness) ? { harness: stringValue(worker.harness) } : {}),
|
|
112
|
+
...(stringValue(worker.role) ? { role: stringValue(worker.role) } : {}),
|
|
113
|
+
...(stringValue(worker.state) ? { state: stringValue(worker.state) } : {}),
|
|
114
|
+
connected: connectedTo(input.sessions, target),
|
|
115
|
+
};
|
|
116
|
+
})
|
|
117
|
+
.filter((member): member is TeamMember => Boolean(member));
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
teamId,
|
|
121
|
+
self: { id: input.selfId, ...(workerId ? { workerId } : {}), isManager },
|
|
122
|
+
manager: managerTarget
|
|
123
|
+
? { target: managerTarget, connected: connectedTo(input.sessions, managerTarget) }
|
|
124
|
+
: { target: input.selfId, connected: true },
|
|
125
|
+
coworkers,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function resolveBossIntercomTeam(input: {
|
|
130
|
+
selfId: string;
|
|
131
|
+
sessions: TeamSession[];
|
|
132
|
+
scope: BossTeamScope;
|
|
133
|
+
}): IntercomTeam {
|
|
134
|
+
const { scope } = input;
|
|
135
|
+
if (!scope.present || !scope.valid || !scope.restricted) {
|
|
136
|
+
throw new Error("error" in scope ? scope.error : "Boss team-only metadata is not active");
|
|
137
|
+
}
|
|
138
|
+
const selfError = bossSelfSessionError(scope, input.selfId);
|
|
139
|
+
if (selfError) throw new Error(selfError);
|
|
140
|
+
|
|
141
|
+
const isManager = scope.role === "manager";
|
|
142
|
+
const managerSession = resolveBossLiveSession(input.sessions, scope.managerTarget);
|
|
143
|
+
const controllerSession = resolveBossLiveSession(input.sessions, scope.controllerTarget);
|
|
144
|
+
const manager = isManager
|
|
145
|
+
? { target: input.selfId, connected: true }
|
|
146
|
+
: managerSession
|
|
147
|
+
? { target: scope.managerTarget, connected: true }
|
|
148
|
+
: undefined;
|
|
149
|
+
const controller = isManager && controllerSession
|
|
150
|
+
? { target: scope.controllerTarget, connected: true }
|
|
151
|
+
: undefined;
|
|
152
|
+
const excludedIds = new Set([input.selfId, managerSession?.id, controllerSession?.id].filter((id): id is string => Boolean(id)));
|
|
153
|
+
const coworkerIds = new Set<string>();
|
|
154
|
+
const coworkers: TeamMember[] = [];
|
|
155
|
+
for (const target of scope.teamTargets) {
|
|
156
|
+
const session = resolveBossLiveSession(input.sessions, target);
|
|
157
|
+
if (!session || excludedIds.has(session.id) || coworkerIds.has(session.id)) continue;
|
|
158
|
+
coworkerIds.add(session.id);
|
|
159
|
+
coworkers.push({ id: session.id, target, connected: true });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
...(manager ? { teamId: manager.target } : {}),
|
|
164
|
+
self: { id: input.selfId, isManager },
|
|
165
|
+
...(manager ? { manager } : {}),
|
|
166
|
+
...(controller ? { controller } : {}),
|
|
167
|
+
coworkers,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function formatIntercomTeam(team: IntercomTeam): string {
|
|
172
|
+
const manager = team.manager
|
|
173
|
+
? `${team.manager.target} [${team.manager.connected ? "connected" : "not connected"}]`
|
|
174
|
+
: "unknown";
|
|
175
|
+
const lines = [
|
|
176
|
+
`Manager: ${manager}`,
|
|
177
|
+
`You: ${team.self.id}${team.self.isManager ? " [manager]" : ""}`,
|
|
178
|
+
];
|
|
179
|
+
if (team.controller) {
|
|
180
|
+
lines.push(`Controller: ${team.controller.target} [connected]`);
|
|
181
|
+
}
|
|
182
|
+
if (team.coworkers.length === 0) {
|
|
183
|
+
lines.push("Coworkers: none");
|
|
184
|
+
} else {
|
|
185
|
+
lines.push("Coworkers:");
|
|
186
|
+
for (const coworker of team.coworkers) {
|
|
187
|
+
const metadata = [coworker.harness, coworker.role, coworker.state].filter(Boolean).join(", ");
|
|
188
|
+
lines.push(`- ${coworker.id} target=${coworker.target}${metadata ? ` (${metadata})` : ""} [${coworker.connected ? "connected" : "not connected"}]`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return lines.join("\n");
|
|
192
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { BrokerCapabilityAdvertisement } from "@ctliz/agent-intercom-core/boss";
|
|
2
|
+
import type { IntercomCommonControlEnvelope } from "./control.ts";
|
|
3
|
+
import type {
|
|
4
|
+
BossParticipantRegistrationMetadata,
|
|
5
|
+
BossSessionMetadata,
|
|
6
|
+
} from "./broker/boss-adapter.ts";
|
|
7
|
+
|
|
8
|
+
export interface SessionInfo {
|
|
9
|
+
id: string;
|
|
10
|
+
name?: string;
|
|
11
|
+
cwd: string;
|
|
12
|
+
model: string;
|
|
13
|
+
pid: number;
|
|
14
|
+
startedAt: number;
|
|
15
|
+
lastActivity: number;
|
|
16
|
+
status?: string;
|
|
17
|
+
peerUid?: number;
|
|
18
|
+
trustedLocal?: boolean;
|
|
19
|
+
origin?: "local" | "remote";
|
|
20
|
+
remoteHostId?: string;
|
|
21
|
+
parentSessionId?: string;
|
|
22
|
+
rootSessionId?: string;
|
|
23
|
+
generation?: number;
|
|
24
|
+
canDelegate?: boolean;
|
|
25
|
+
depth?: number;
|
|
26
|
+
maxDepth?: number;
|
|
27
|
+
maxChildren?: number;
|
|
28
|
+
/** Broker-owned live binding; never accepted from SessionRegistration. */
|
|
29
|
+
boss?: BossSessionMetadata;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Message {
|
|
33
|
+
id: string;
|
|
34
|
+
timestamp: number;
|
|
35
|
+
replyTo?: string;
|
|
36
|
+
expectsReply?: boolean;
|
|
37
|
+
content: {
|
|
38
|
+
text: string;
|
|
39
|
+
attachments?: Attachment[];
|
|
40
|
+
control?: IntercomCommonControlEnvelope;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Attachment {
|
|
45
|
+
type: "file" | "snippet" | "context";
|
|
46
|
+
name: string;
|
|
47
|
+
content: string;
|
|
48
|
+
language?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type SessionRegistration = Omit<
|
|
52
|
+
SessionInfo,
|
|
53
|
+
"id" | "peerUid" | "trustedLocal" | "origin" | "remoteHostId" | "parentSessionId" | "rootSessionId" | "generation" | "canDelegate" | "depth" | "maxDepth" | "maxChildren"
|
|
54
|
+
| "boss"
|
|
55
|
+
> & {
|
|
56
|
+
/** Ephemeral identity shared only by reconnects from one live runtime. */
|
|
57
|
+
runtimeInstanceId?: string;
|
|
58
|
+
/** Optional broker-authority metadata. Rejected unless boss-run-v1 is exactly negotiated. */
|
|
59
|
+
boss?: BossParticipantRegistrationMetadata;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export interface RemoteEnrollmentAccess {
|
|
63
|
+
enrollmentToken: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface RemoteSessionAccess {
|
|
67
|
+
sessionCredential: string;
|
|
68
|
+
sessionId: string;
|
|
69
|
+
generation: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type RemoteRegistrationAccess = RemoteEnrollmentAccess | RemoteSessionAccess;
|
|
73
|
+
|
|
74
|
+
export interface RemoteAccessMetadata {
|
|
75
|
+
origin: "remote";
|
|
76
|
+
remoteHostId: string;
|
|
77
|
+
parentSessionId: string;
|
|
78
|
+
rootSessionId: string;
|
|
79
|
+
generation: number;
|
|
80
|
+
canDelegate: boolean;
|
|
81
|
+
depth: number;
|
|
82
|
+
maxDepth: number;
|
|
83
|
+
maxChildren: number;
|
|
84
|
+
sessionCredential?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface RemotePrincipalSummary {
|
|
88
|
+
id: string;
|
|
89
|
+
name: string;
|
|
90
|
+
parentSessionId: string;
|
|
91
|
+
rootSessionId: string;
|
|
92
|
+
remoteHostId: string;
|
|
93
|
+
generation: number;
|
|
94
|
+
policy: "remote-tree";
|
|
95
|
+
canDelegate: boolean;
|
|
96
|
+
depth: number;
|
|
97
|
+
maxDepth: number;
|
|
98
|
+
maxChildren: number;
|
|
99
|
+
state: "active" | "revoked";
|
|
100
|
+
expiresAt: number;
|
|
101
|
+
createdAt: number;
|
|
102
|
+
updatedAt: number;
|
|
103
|
+
connected: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface RemoteAccessContract {
|
|
107
|
+
feature: "remote-access-v1";
|
|
108
|
+
policySemanticsVersion: number;
|
|
109
|
+
policySemanticsHash: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type DeliveryFailureCode =
|
|
113
|
+
| "INVALID_MESSAGE"
|
|
114
|
+
| "CONTROL_DISPATCH_UNAVAILABLE"
|
|
115
|
+
| "SESSION_NOT_FOUND"
|
|
116
|
+
| "AMBIGUOUS_TARGET"
|
|
117
|
+
| "SENDER_NOT_FOUND"
|
|
118
|
+
| "INVALID_REPLY_TARGET"
|
|
119
|
+
| "MUTUAL_ASK"
|
|
120
|
+
| "ASK_ALREADY_PENDING"
|
|
121
|
+
| "DUPLICATE_MESSAGE_ID"
|
|
122
|
+
| "CONFLICTING_MESSAGE_ID"
|
|
123
|
+
| "TOO_MANY_PENDING_DELIVERIES"
|
|
124
|
+
| "TOO_MANY_PENDING_ASKS"
|
|
125
|
+
| "RECIPIENT_DISCONNECTED"
|
|
126
|
+
| "SENDER_DISCONNECTED"
|
|
127
|
+
| "DELIVERY_TIMEOUT";
|
|
128
|
+
|
|
129
|
+
export type BrokerErrorCode =
|
|
130
|
+
| "PROTOCOL_MISMATCH"
|
|
131
|
+
| "INVALID_REQUEST"
|
|
132
|
+
| "SESSION_ID_IN_USE"
|
|
133
|
+
| "ACCESS_DENIED"
|
|
134
|
+
| "BOSS_FEATURE_UNAVAILABLE"
|
|
135
|
+
| "REMOTE_ACCESS_INCOMPATIBLE"
|
|
136
|
+
| "RATE_LIMITED"
|
|
137
|
+
| "TOO_MANY_SESSIONS";
|
|
138
|
+
|
|
139
|
+
export type AskCancellationReason =
|
|
140
|
+
| "cancelled"
|
|
141
|
+
| "expired"
|
|
142
|
+
| "delivery_failed"
|
|
143
|
+
| "session_disconnected"
|
|
144
|
+
| "authorization_revoked";
|
|
145
|
+
|
|
146
|
+
export type ClientMessage =
|
|
147
|
+
| { type: "health"; requestId: string; stateId?: string }
|
|
148
|
+
| { type: "register"; protocol: string; version: number; session: SessionRegistration; sessionId?: string; stateId?: string; access?: RemoteRegistrationAccess; scopeId?: string }
|
|
149
|
+
| { type: "access_control"; requestId: string; adminToken: string; action: "issue_enrollment"; enrollment: { name: string; parentSessionId: string; rootSessionId: string; remoteHostId: string; ttlMs?: number; expiresAt?: number; canDelegate?: boolean; maxDepth?: number; maxChildren?: number } }
|
|
150
|
+
| { type: "access_control"; requestId: string; adminToken: string; action: "revoke_subtree"; principalId: string }
|
|
151
|
+
| { type: "access_control"; requestId: string; adminToken: string; action: "inspect_tree"; principalId: string }
|
|
152
|
+
| { type: "access_control"; requestId: string; adminToken: string; action: "adopt_subtree"; principalId: string; newParentSessionId: string }
|
|
153
|
+
| { type: "access_control"; requestId: string; access: RemoteSessionAccess; action: "issue_child_enrollment"; enrollment: { name: string; ttlMs?: number; expiresAt?: number; canDelegate?: boolean; maxDepth?: number; maxChildren?: number } }
|
|
154
|
+
| { type: "access_control"; requestId: string; access: RemoteSessionAccess; action: "inspect_tree"; principalId?: string }
|
|
155
|
+
| { type: "unregister"; preserveAsks?: boolean }
|
|
156
|
+
| { type: "list"; requestId: string }
|
|
157
|
+
| { type: "send"; to: string; message: Message }
|
|
158
|
+
| { type: "message_received"; deliveryId: string }
|
|
159
|
+
| { type: "message_rejected"; deliveryId: string; code: "CONFLICTING_MESSAGE_ID"; reason: string }
|
|
160
|
+
| { type: "defer_ask"; requestId: string; messageId: string }
|
|
161
|
+
| { type: "cancel_ask"; requestId: string; messageId: string }
|
|
162
|
+
| { type: "presence"; name?: string; status?: string; model?: string };
|
|
163
|
+
|
|
164
|
+
export type BrokerMessage =
|
|
165
|
+
| { type: "health_ok"; requestId: string; protocol: string; version: number; endpoint: "local" | "remote"; capabilities?: BrokerCapabilityAdvertisement; remoteAccess?: RemoteAccessContract }
|
|
166
|
+
| { type: "registered"; sessionId: string; protocol: string; version: number; capabilities?: BrokerCapabilityAdvertisement; boss?: BossSessionMetadata; remoteAccess?: RemoteAccessContract; access?: RemoteAccessMetadata }
|
|
167
|
+
| { type: "access_control_result"; requestId: string; action: "issue_enrollment"; enrollmentToken: string; expiresAt: number }
|
|
168
|
+
| { type: "access_control_result"; requestId: string; action: "revoke_subtree"; changedPrincipalIds: string[] }
|
|
169
|
+
| { type: "access_control_result"; requestId: string; action: "inspect_tree"; principals: RemotePrincipalSummary[] }
|
|
170
|
+
| { type: "access_control_result"; requestId: string; action: "adopt_subtree"; principals: RemotePrincipalSummary[] }
|
|
171
|
+
| { type: "access_control_result"; requestId: string; action: "issue_child_enrollment"; enrollmentToken: string; expiresAt: number; parentSessionId: string }
|
|
172
|
+
| { type: "sessions"; requestId: string; sessions: SessionInfo[] }
|
|
173
|
+
| { type: "message"; deliveryId: string; from: SessionInfo; message: Message }
|
|
174
|
+
| { type: "presence_update"; session: SessionInfo }
|
|
175
|
+
| { type: "session_joined"; session: SessionInfo }
|
|
176
|
+
| { type: "session_left"; sessionId: string }
|
|
177
|
+
| { type: "error"; code: BrokerErrorCode; error: string }
|
|
178
|
+
| { type: "delivery_accepted"; messageId: string; deliveryId: string }
|
|
179
|
+
| { type: "delivered"; messageId: string; deliveryId: string }
|
|
180
|
+
| { type: "delivery_failed"; messageId: string; accepted: boolean; code: DeliveryFailureCode; reason: string }
|
|
181
|
+
| { type: "ask_deferred"; messageId: string; fromSessionId: string }
|
|
182
|
+
| { type: "ask_cancelled"; messageId: string; fromSessionId: string; reason: AskCancellationReason }
|
|
183
|
+
| { type: "ask_control_result"; requestId: string; action: "defer" | "cancel"; messageId: string; applied: boolean };
|
package/ui/compose.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
2
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { IntercomClient } from "../broker/client.ts";
|
|
5
|
+
import type { SessionInfo } from "../types.ts";
|
|
6
|
+
import { sanitizeDisplayText } from "./session-identity.ts";
|
|
7
|
+
|
|
8
|
+
export interface ComposeResult {
|
|
9
|
+
sent: boolean;
|
|
10
|
+
messageId?: string;
|
|
11
|
+
text?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class ComposeOverlay implements Component {
|
|
15
|
+
private tui: TUI;
|
|
16
|
+
private theme: Theme;
|
|
17
|
+
private keybindings: KeybindingsManager;
|
|
18
|
+
private target: SessionInfo;
|
|
19
|
+
private targetLabel: string;
|
|
20
|
+
private client: IntercomClient;
|
|
21
|
+
private done: (result: ComposeResult) => void;
|
|
22
|
+
private inputBuffer: string = "";
|
|
23
|
+
private pasteBuffer: string | null = null;
|
|
24
|
+
private sending: boolean = false;
|
|
25
|
+
private error: string | null = null;
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
tui: TUI,
|
|
29
|
+
theme: Theme,
|
|
30
|
+
keybindings: KeybindingsManager,
|
|
31
|
+
target: SessionInfo,
|
|
32
|
+
targetLabel: string,
|
|
33
|
+
client: IntercomClient,
|
|
34
|
+
done: (result: ComposeResult) => void,
|
|
35
|
+
) {
|
|
36
|
+
this.tui = tui;
|
|
37
|
+
this.theme = theme;
|
|
38
|
+
this.keybindings = keybindings;
|
|
39
|
+
this.target = target;
|
|
40
|
+
this.targetLabel = targetLabel;
|
|
41
|
+
this.client = client;
|
|
42
|
+
this.done = done;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
invalidate(): void {}
|
|
46
|
+
|
|
47
|
+
handleInput(data: string): void {
|
|
48
|
+
if (this.sending) return;
|
|
49
|
+
|
|
50
|
+
if (this.pasteBuffer !== null) {
|
|
51
|
+
this.consumePaste(data);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const pasteStart = data.indexOf("\x1b[200~");
|
|
56
|
+
if (pasteStart !== -1) {
|
|
57
|
+
const beforePaste = data.slice(0, pasteStart);
|
|
58
|
+
if (beforePaste) this.insertPrintable(beforePaste);
|
|
59
|
+
this.pasteBuffer = "";
|
|
60
|
+
this.consumePaste(data.slice(pasteStart + 6));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
65
|
+
this.done({ sent: false });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (this.keybindings.matches(data, "tui.input.newLine")) {
|
|
70
|
+
this.inputBuffer += "\n";
|
|
71
|
+
this.tui.requestRender();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (
|
|
76
|
+
this.keybindings.matches(data, "tui.input.submit")
|
|
77
|
+
|| this.keybindings.matches(data, "tui.select.confirm")
|
|
78
|
+
) {
|
|
79
|
+
if (this.inputBuffer.trim()) {
|
|
80
|
+
this.sendMessage();
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (this.keybindings.matches(data, "tui.editor.deleteCharBackward")) {
|
|
86
|
+
this.inputBuffer = [...this.inputBuffer].slice(0, -1).join("");
|
|
87
|
+
this.tui.requestRender();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (data.startsWith("\x1b")) return;
|
|
92
|
+
this.insertPrintable(data);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private consumePaste(data: string): void {
|
|
96
|
+
if (this.pasteBuffer === null) return;
|
|
97
|
+
const pasteEnd = data.indexOf("\x1b[201~");
|
|
98
|
+
if (pasteEnd === -1) {
|
|
99
|
+
this.pasteBuffer += data;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const pastedText = this.pasteBuffer + data.slice(0, pasteEnd);
|
|
104
|
+
this.pasteBuffer = null;
|
|
105
|
+
this.inputBuffer += this.normalizeInput(pastedText, true);
|
|
106
|
+
this.tui.requestRender();
|
|
107
|
+
|
|
108
|
+
const remaining = data.slice(pasteEnd + 6);
|
|
109
|
+
if (remaining) this.handleInput(remaining);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private normalizeInput(data: string, preserveNewlines = false): string {
|
|
113
|
+
return data
|
|
114
|
+
.replace(/\r\n?/g, "\n")
|
|
115
|
+
.replace(/\t/g, " ")
|
|
116
|
+
.split("")
|
|
117
|
+
.filter(character => {
|
|
118
|
+
if (preserveNewlines && character === "\n") return true;
|
|
119
|
+
const code = character.charCodeAt(0);
|
|
120
|
+
return code >= 32 && code !== 127 && !(code >= 128 && code <= 159);
|
|
121
|
+
})
|
|
122
|
+
.join("");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private insertPrintable(data: string): void {
|
|
126
|
+
const printable = this.normalizeInput(data);
|
|
127
|
+
if (!printable) return;
|
|
128
|
+
this.inputBuffer += printable;
|
|
129
|
+
this.tui.requestRender();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private async sendMessage(): Promise<void> {
|
|
133
|
+
this.sending = true;
|
|
134
|
+
this.error = null;
|
|
135
|
+
this.tui.requestRender();
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const result = await this.client.send(this.target.id, {
|
|
139
|
+
text: this.inputBuffer.trim(),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (!result.delivered) {
|
|
143
|
+
this.error = sanitizeDisplayText(
|
|
144
|
+
result.reason,
|
|
145
|
+
"Message not delivered. Session may not exist or has disconnected.",
|
|
146
|
+
);
|
|
147
|
+
this.sending = false;
|
|
148
|
+
this.tui.requestRender();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
this.done({
|
|
153
|
+
sent: true,
|
|
154
|
+
messageId: result.id,
|
|
155
|
+
text: this.inputBuffer.trim(),
|
|
156
|
+
});
|
|
157
|
+
} catch (error) {
|
|
158
|
+
this.error = sanitizeDisplayText(error instanceof Error ? error.message : String(error), "Message failed");
|
|
159
|
+
this.sending = false;
|
|
160
|
+
this.tui.requestRender();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
render(width: number): string[] {
|
|
165
|
+
const innerWidth = Math.max(1, Math.min(width, 72));
|
|
166
|
+
if (innerWidth === 1) {
|
|
167
|
+
return [this.theme.fg("accent", "│")];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const contentWidth = Math.max(0, innerWidth - 2);
|
|
171
|
+
const sendKeys = this.keybindings.getKeys("tui.input.submit");
|
|
172
|
+
const newlineKeys = this.keybindings.getKeys("tui.input.newLine");
|
|
173
|
+
const footer = `${sendKeys.join("/")}: Send • ${newlineKeys.join("/")}: New line • ${this.keybindings.getKeys("tui.select.cancel").join("/")}: Close`;
|
|
174
|
+
const border = (text: string) => this.theme.fg("accent", text);
|
|
175
|
+
const row = (text = "") => {
|
|
176
|
+
const clipped = truncateToWidth(text, contentWidth, "", true);
|
|
177
|
+
return `${border("│")}${clipped}${" ".repeat(Math.max(0, contentWidth - visibleWidth(clipped)))}${border("│")}`;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const lines: string[] = [];
|
|
181
|
+
const targetLabel = sanitizeDisplayText(
|
|
182
|
+
this.targetLabel,
|
|
183
|
+
sanitizeDisplayText(this.target.id, "Unknown session"),
|
|
184
|
+
);
|
|
185
|
+
const targetCwd = sanitizeDisplayText(this.target.cwd, "Unknown path");
|
|
186
|
+
const targetModel = sanitizeDisplayText(this.target.model, "Unknown model");
|
|
187
|
+
lines.push(border(`╭${"─".repeat(contentWidth)}╮`));
|
|
188
|
+
lines.push(row(this.theme.bold(` Send to: ${targetLabel}`)));
|
|
189
|
+
lines.push(row(this.theme.fg("dim", ` ${targetCwd} • ${targetModel}`)));
|
|
190
|
+
lines.push(border(`├${"─".repeat(contentWidth)}┤`));
|
|
191
|
+
lines.push(row());
|
|
192
|
+
|
|
193
|
+
if (this.sending) {
|
|
194
|
+
lines.push(row(this.theme.fg("dim", " Sending...")));
|
|
195
|
+
} else if (this.error) {
|
|
196
|
+
lines.push(row(this.theme.fg("error", ` Error: ${this.error}`)));
|
|
197
|
+
lines.push(row());
|
|
198
|
+
lines.push(...this.renderInputRows(contentWidth, row));
|
|
199
|
+
} else {
|
|
200
|
+
lines.push(...this.renderInputRows(contentWidth, row));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
lines.push(row());
|
|
204
|
+
lines.push(border(`├${"─".repeat(contentWidth)}┤`));
|
|
205
|
+
lines.push(row(this.theme.fg("dim", ` ${footer}`)));
|
|
206
|
+
lines.push(border(`╰${"─".repeat(contentWidth)}╯`));
|
|
207
|
+
|
|
208
|
+
return lines;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private renderInputRows(contentWidth: number, row: (text?: string) => string): string[] {
|
|
212
|
+
const promptWidth = Math.min(3, contentWidth);
|
|
213
|
+
const textWidth = Math.max(1, contentWidth - promptWidth);
|
|
214
|
+
const logicalLines = this.inputBuffer.split("\n");
|
|
215
|
+
const rendered: string[] = [];
|
|
216
|
+
|
|
217
|
+
logicalLines.forEach((logicalLine, logicalIndex) => {
|
|
218
|
+
const wrapped = wrapTextWithAnsi(logicalLine, textWidth);
|
|
219
|
+
const visualLines = wrapped.length ? wrapped : [""];
|
|
220
|
+
visualLines.forEach((visualLine, visualIndex) => {
|
|
221
|
+
const isFirst = logicalIndex === 0 && visualIndex === 0;
|
|
222
|
+
const isLast = logicalIndex === logicalLines.length - 1 && visualIndex === visualLines.length - 1;
|
|
223
|
+
rendered.push(row(`${isFirst ? " > " : " "}${visualLine}${isLast ? "█" : ""}`));
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return rendered.slice(-8);
|
|
228
|
+
}
|
|
229
|
+
}
|