@ai-devkit/agent-manager 0.38.0 → 0.39.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.
- package/dist/__tests__/harnesses/devin/DevinAdapter.test.js +597 -0
- package/dist/__tests__/harnesses/devin/DevinAdapter.test.js.map +1 -0
- package/dist/__tests__/harnesses/devin/DevinAgentMapper.test.js +91 -0
- package/dist/__tests__/harnesses/devin/DevinAgentMapper.test.js.map +1 -0
- package/dist/__tests__/harnesses/devin/DevinSessionLocator.test.js +192 -0
- package/dist/__tests__/harnesses/devin/DevinSessionLocator.test.js.map +1 -0
- package/dist/__tests__/harnesses/devin/DevinSessionParser.test.js +407 -0
- package/dist/__tests__/harnesses/devin/DevinSessionParser.test.js.map +1 -0
- package/dist/__tests__/harnesses/devin/fixtures.js +95 -0
- package/dist/__tests__/harnesses/devin/fixtures.js.map +1 -0
- package/dist/__tests__/harnesses/readiness/AgentReadiness.test.js +64 -1
- package/dist/__tests__/harnesses/readiness/AgentReadiness.test.js.map +1 -1
- package/dist/__tests__/terminal/GhosttyBackend.test.js +334 -0
- package/dist/__tests__/terminal/GhosttyBackend.test.js.map +1 -0
- package/dist/adapters/AgentAdapter.d.ts +1 -1
- package/dist/adapters/AgentAdapter.d.ts.map +1 -1
- package/dist/adapters/AgentAdapter.js +2 -1
- package/dist/adapters/AgentAdapter.js.map +1 -1
- package/dist/harnesses/devin/DevinAdapter.d.ts +39 -0
- package/dist/harnesses/devin/DevinAdapter.d.ts.map +1 -0
- package/dist/harnesses/devin/DevinAdapter.js +177 -0
- package/dist/harnesses/devin/DevinAdapter.js.map +1 -0
- package/dist/harnesses/devin/DevinAgentMapper.d.ts +11 -0
- package/dist/harnesses/devin/DevinAgentMapper.d.ts.map +1 -0
- package/dist/harnesses/devin/DevinAgentMapper.js +35 -0
- package/dist/harnesses/devin/DevinAgentMapper.js.map +1 -0
- package/dist/harnesses/devin/DevinSessionLocator.d.ts +46 -0
- package/dist/harnesses/devin/DevinSessionLocator.d.ts.map +1 -0
- package/dist/harnesses/devin/DevinSessionLocator.js +176 -0
- package/dist/harnesses/devin/DevinSessionLocator.js.map +1 -0
- package/dist/harnesses/devin/DevinSessionParser.d.ts +29 -0
- package/dist/harnesses/devin/DevinSessionParser.d.ts.map +1 -0
- package/dist/harnesses/devin/DevinSessionParser.js +166 -0
- package/dist/harnesses/devin/DevinSessionParser.js.map +1 -0
- package/dist/harnesses/devin/readiness.d.ts +3 -0
- package/dist/harnesses/devin/readiness.d.ts.map +1 -0
- package/dist/harnesses/devin/readiness.js +39 -0
- package/dist/harnesses/devin/readiness.js.map +1 -0
- package/dist/harnesses/index.d.ts.map +1 -1
- package/dist/harnesses/index.js +3 -1
- package/dist/harnesses/index.js.map +1 -1
- package/dist/harnesses/readiness/AgentReadiness.d.ts.map +1 -1
- package/dist/harnesses/readiness/AgentReadiness.js +3 -1
- package/dist/harnesses/readiness/AgentReadiness.js.map +1 -1
- package/dist/harnesses/readiness/types.d.ts +1 -1
- package/dist/harnesses/readiness/types.d.ts.map +1 -1
- package/dist/harnesses/runtimeProfiles.d.ts.map +1 -1
- package/dist/harnesses/runtimeProfiles.js +2 -1
- package/dist/harnesses/runtimeProfiles.js.map +1 -1
- package/dist/terminal/TtyWriter.d.ts.map +1 -1
- package/dist/terminal/TtyWriter.js +4 -3
- package/dist/terminal/TtyWriter.js.map +1 -1
- package/dist/terminal/backends/ghostty.d.ts +3 -0
- package/dist/terminal/backends/ghostty.d.ts.map +1 -0
- package/dist/terminal/backends/ghostty.js +161 -0
- package/dist/terminal/backends/ghostty.js.map +1 -0
- package/dist/terminal/backends/index.d.ts.map +1 -1
- package/dist/terminal/backends/index.js +2 -0
- package/dist/terminal/backends/index.js.map +1 -1
- package/dist/terminal/backends/shared.d.ts +2 -0
- package/dist/terminal/backends/shared.d.ts.map +1 -1
- package/dist/terminal/backends/shared.js +4 -1
- package/dist/terminal/backends/shared.js.map +1 -1
- package/dist/terminal/types.d.ts +1 -0
- package/dist/terminal/types.d.ts.map +1 -1
- package/dist/terminal/types.js +1 -0
- package/dist/terminal/types.js.map +1 -1
- package/dist/utils/process.d.ts +2 -0
- package/dist/utils/process.d.ts.map +1 -1
- package/dist/utils/process.js +3 -0
- package/dist/utils/process.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/harnesses/devin/DevinAdapter.test.ts +292 -0
- package/src/__tests__/harnesses/devin/DevinAgentMapper.test.ts +88 -0
- package/src/__tests__/harnesses/devin/DevinSessionLocator.test.ts +139 -0
- package/src/__tests__/harnesses/devin/DevinSessionParser.test.ts +218 -0
- package/src/__tests__/harnesses/devin/fixtures.ts +144 -0
- package/src/__tests__/harnesses/readiness/AgentReadiness.test.ts +41 -0
- package/src/__tests__/terminal/GhosttyBackend.test.ts +271 -0
- package/src/adapters/AgentAdapter.ts +1 -0
- package/src/harnesses/devin/DevinAdapter.ts +224 -0
- package/src/harnesses/devin/DevinAgentMapper.ts +43 -0
- package/src/harnesses/devin/DevinSessionLocator.ts +230 -0
- package/src/harnesses/devin/DevinSessionParser.ts +236 -0
- package/src/harnesses/devin/readiness.ts +36 -0
- package/src/harnesses/index.ts +2 -0
- package/src/harnesses/readiness/AgentReadiness.ts +2 -0
- package/src/harnesses/runtimeProfiles.ts +1 -0
- package/src/terminal/TtyWriter.ts +5 -5
- package/src/terminal/backends/ghostty.ts +180 -0
- package/src/terminal/backends/index.ts +2 -0
- package/src/terminal/backends/shared.ts +6 -1
- package/src/terminal/types.ts +1 -0
- package/src/utils/process.ts +5 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import type { ConversationMessage, ConversationOptions } from "../../adapters/AgentAdapter.js";
|
|
2
|
+
import { sliceTail } from "../../utils/jsonlTail.js";
|
|
3
|
+
import { SUMMARY_MAX_LENGTH, truncate } from "../shared.js";
|
|
4
|
+
import type Database from "better-sqlite3";
|
|
5
|
+
|
|
6
|
+
export interface DevinSessionStats {
|
|
7
|
+
lastRole: string | null;
|
|
8
|
+
/** Epoch milliseconds of the newest message node. */
|
|
9
|
+
lastTimeUpdated: number;
|
|
10
|
+
summary: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface DevinChatMessage {
|
|
14
|
+
message_id?: string;
|
|
15
|
+
role?: string;
|
|
16
|
+
content?: unknown;
|
|
17
|
+
tool_calls?: { name?: string; function?: { name?: string } }[];
|
|
18
|
+
thinking?: { thinking?: string } | string;
|
|
19
|
+
name?: string;
|
|
20
|
+
metadata?: { is_user_input?: boolean };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface MessageNodeRow {
|
|
24
|
+
node_id: number;
|
|
25
|
+
chat_message: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const EMPTY_STATS: DevinSessionStats = {
|
|
29
|
+
lastRole: null,
|
|
30
|
+
lastTimeUpdated: 0,
|
|
31
|
+
summary: "",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Rows fetched per requested tail message; revisions/tools eat into the window. */
|
|
35
|
+
const TAIL_OVERFETCH = 8;
|
|
36
|
+
const TOOL_CONTENT_MAX = 500;
|
|
37
|
+
|
|
38
|
+
export class DevinSessionParser {
|
|
39
|
+
getConversation(
|
|
40
|
+
db: Database.Database,
|
|
41
|
+
sessionId: string,
|
|
42
|
+
options?: ConversationOptions,
|
|
43
|
+
): ConversationMessage[] {
|
|
44
|
+
const verbose = options?.verbose ?? false;
|
|
45
|
+
const tail =
|
|
46
|
+
options?.tail && Number.isInteger(options.tail) && options.tail > 0
|
|
47
|
+
? options.tail
|
|
48
|
+
: undefined;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
// LIMIT -1 = unbounded in SQLite; one query serves the full and tail paths.
|
|
52
|
+
const limit = tail ? tail * TAIL_OVERFETCH : -1;
|
|
53
|
+
const rows = db
|
|
54
|
+
.prepare<[string, number], MessageNodeRow>(`
|
|
55
|
+
SELECT node_id, chat_message
|
|
56
|
+
FROM message_nodes
|
|
57
|
+
WHERE session_id = ?
|
|
58
|
+
ORDER BY node_id DESC
|
|
59
|
+
LIMIT ?
|
|
60
|
+
`)
|
|
61
|
+
.all(sessionId, limit)
|
|
62
|
+
.reverse();
|
|
63
|
+
|
|
64
|
+
const messages: ConversationMessage[] = [];
|
|
65
|
+
const latestNodeByMessageId = new Map<string, number>();
|
|
66
|
+
const parsed: { nodeId: number; message: DevinChatMessage }[] = [];
|
|
67
|
+
|
|
68
|
+
for (const row of rows) {
|
|
69
|
+
const message = this.parseChatMessage(row.chat_message);
|
|
70
|
+
if (!message) continue;
|
|
71
|
+
parsed.push({ nodeId: row.node_id, message });
|
|
72
|
+
if (message.message_id) {
|
|
73
|
+
latestNodeByMessageId.set(
|
|
74
|
+
message.message_id,
|
|
75
|
+
Math.max(latestNodeByMessageId.get(message.message_id) ?? 0, row.node_id),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const { nodeId, message } of parsed) {
|
|
81
|
+
if (
|
|
82
|
+
message.message_id &&
|
|
83
|
+
latestNodeByMessageId.get(message.message_id) !== nodeId
|
|
84
|
+
) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const role = message.role;
|
|
89
|
+
if (role === "system") continue;
|
|
90
|
+
|
|
91
|
+
const content = typeof message.content === "string" ? message.content : "";
|
|
92
|
+
|
|
93
|
+
if (role === "user") {
|
|
94
|
+
if (message.metadata?.is_user_input === false || !content.trim()) continue;
|
|
95
|
+
messages.push({ role: "user", content });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (role === "assistant") {
|
|
100
|
+
const thinking =
|
|
101
|
+
typeof message.thinking === "string"
|
|
102
|
+
? message.thinking
|
|
103
|
+
: message.thinking?.thinking;
|
|
104
|
+
if (verbose && thinking) {
|
|
105
|
+
messages.push({ role: "assistant", content: `[thinking] ${thinking}` });
|
|
106
|
+
}
|
|
107
|
+
if (content.trim()) messages.push({ role: "assistant", content });
|
|
108
|
+
if (verbose) {
|
|
109
|
+
for (const call of message.tool_calls ?? []) {
|
|
110
|
+
const name = call.function?.name ?? call.name ?? "tool";
|
|
111
|
+
messages.push({ role: "assistant", content: `[tool: ${name}]` });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (role === "tool" && verbose) {
|
|
118
|
+
const label = message.name ? `[tool: ${message.name}]` : "[tool]";
|
|
119
|
+
messages.push({
|
|
120
|
+
role: "assistant",
|
|
121
|
+
content: `${label} ${truncate(content.trim(), TOOL_CONTENT_MAX)}`.trim(),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return sliceTail(messages, tail);
|
|
127
|
+
} catch {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
getSessionStats(db: Database.Database, sessionId: string): DevinSessionStats {
|
|
133
|
+
try {
|
|
134
|
+
const frontier = db
|
|
135
|
+
.prepare<[string], { chat_message: string }>(`
|
|
136
|
+
SELECT chat_message
|
|
137
|
+
FROM message_nodes
|
|
138
|
+
WHERE session_id = ?
|
|
139
|
+
ORDER BY node_id DESC
|
|
140
|
+
LIMIT 1
|
|
141
|
+
`)
|
|
142
|
+
.get(sessionId);
|
|
143
|
+
|
|
144
|
+
const heartbeat = db
|
|
145
|
+
.prepare<[string], { maxCreated: number | null }>(`
|
|
146
|
+
SELECT MAX(created_at) AS maxCreated
|
|
147
|
+
FROM message_nodes
|
|
148
|
+
WHERE session_id = ?
|
|
149
|
+
`)
|
|
150
|
+
.get(sessionId);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
lastRole: this.parseChatMessage(frontier?.chat_message ?? "")?.role ?? null,
|
|
154
|
+
lastTimeUpdated: (heartbeat?.maxCreated ?? 0) * 1000,
|
|
155
|
+
summary: this.getLastUserPrompt(db, sessionId),
|
|
156
|
+
};
|
|
157
|
+
} catch {
|
|
158
|
+
return EMPTY_STATS;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Most recent typed prompt for the "working on" summary, matching the
|
|
164
|
+
* last-user-message convention of the other adapters.
|
|
165
|
+
*/
|
|
166
|
+
getLastUserPrompt(db: Database.Database, sessionId: string): string {
|
|
167
|
+
return this.userPrompt(db, sessionId, "DESC");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* First typed prompt for session-list identity (`firstUserMessage`).
|
|
172
|
+
*/
|
|
173
|
+
getFirstUserMessage(db: Database.Database, sessionId: string): string {
|
|
174
|
+
return this.userPrompt(db, sessionId, "ASC");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Typed prompt in the given direction. `prompt_history` is authoritative;
|
|
179
|
+
* shell `!` commands and `/` slash commands are skipped. Falls back to a
|
|
180
|
+
* bounded `is_user_input` node scan in the same direction.
|
|
181
|
+
*/
|
|
182
|
+
private userPrompt(
|
|
183
|
+
db: Database.Database,
|
|
184
|
+
sessionId: string,
|
|
185
|
+
direction: "ASC" | "DESC",
|
|
186
|
+
): string {
|
|
187
|
+
try {
|
|
188
|
+
const prompt = db
|
|
189
|
+
.prepare<[string], { content: string }>(`
|
|
190
|
+
SELECT content
|
|
191
|
+
FROM prompt_history
|
|
192
|
+
WHERE session_id = ? AND is_shell = 0 AND content NOT LIKE '/%'
|
|
193
|
+
ORDER BY timestamp ${direction}
|
|
194
|
+
LIMIT 1
|
|
195
|
+
`)
|
|
196
|
+
.get(sessionId);
|
|
197
|
+
if (prompt?.content?.trim()) return truncate(prompt.content.trim(), SUMMARY_MAX_LENGTH);
|
|
198
|
+
} catch {
|
|
199
|
+
/* fall through to message_nodes */
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
const rows = db
|
|
204
|
+
.prepare<[string], { chat_message: string }>(`
|
|
205
|
+
SELECT chat_message
|
|
206
|
+
FROM message_nodes
|
|
207
|
+
WHERE session_id = ? AND json_extract(chat_message, '$.role') = 'user'
|
|
208
|
+
ORDER BY node_id ${direction}
|
|
209
|
+
LIMIT 8
|
|
210
|
+
`)
|
|
211
|
+
.all(sessionId);
|
|
212
|
+
for (const row of rows) {
|
|
213
|
+
const message = this.parseChatMessage(row.chat_message);
|
|
214
|
+
if (
|
|
215
|
+
message?.role === "user" &&
|
|
216
|
+
message.metadata?.is_user_input !== false &&
|
|
217
|
+
typeof message.content === "string" &&
|
|
218
|
+
message.content.trim()
|
|
219
|
+
) {
|
|
220
|
+
return truncate(message.content.trim(), SUMMARY_MAX_LENGTH);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
/* ignore */
|
|
225
|
+
}
|
|
226
|
+
return "";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private parseChatMessage(raw: string): DevinChatMessage | null {
|
|
230
|
+
try {
|
|
231
|
+
return JSON.parse(raw) as DevinChatMessage;
|
|
232
|
+
} catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuthReadinessCheck,
|
|
3
|
+
HarnessReadinessProfile,
|
|
4
|
+
ReadinessRuntime,
|
|
5
|
+
} from "../readiness/types.js";
|
|
6
|
+
|
|
7
|
+
const LOGGED_IN_PATTERN = /^Logged in/m;
|
|
8
|
+
|
|
9
|
+
async function authCheck(runtime: ReadinessRuntime): Promise<AuthReadinessCheck> {
|
|
10
|
+
try {
|
|
11
|
+
const result = await runtime.runCommand("devin", ["auth", "status"]);
|
|
12
|
+
const authenticated = LOGGED_IN_PATTERN.test(result.stdout);
|
|
13
|
+
return {
|
|
14
|
+
state: authenticated ? "authenticated" : "unauthenticated",
|
|
15
|
+
source: "devin auth status",
|
|
16
|
+
provider: null,
|
|
17
|
+
availableProviders: authenticated ? ["devin"] : [],
|
|
18
|
+
status: authenticated ? "pass" : "fail",
|
|
19
|
+
errors: authenticated ? [] : ["Devin is not logged in"],
|
|
20
|
+
};
|
|
21
|
+
} catch {
|
|
22
|
+
return {
|
|
23
|
+
state: "unknown",
|
|
24
|
+
source: "devin auth status",
|
|
25
|
+
provider: null,
|
|
26
|
+
availableProviders: [],
|
|
27
|
+
status: "warn",
|
|
28
|
+
errors: ["Devin authentication probe failed"],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const devinReadiness: HarnessReadinessProfile = {
|
|
34
|
+
configDir: ".config/devin",
|
|
35
|
+
auth: authCheck,
|
|
36
|
+
};
|
package/src/harnesses/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { GrokCliAdapter } from "./grok/GrokCliAdapter.js";
|
|
|
8
8
|
import { KiroAdapter } from "./kiro/KiroAdapter.js";
|
|
9
9
|
import { OpenCodeAdapter } from "./opencode/OpenCodeAdapter.js";
|
|
10
10
|
import { PiAdapter } from "./pi/PiAdapter.js";
|
|
11
|
+
import { DevinAdapter } from "./devin/DevinAdapter.js";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* One instance of every built-in harness adapter, in registration order
|
|
@@ -24,5 +25,6 @@ export function createBuiltinAdapters(): AgentAdapter[] {
|
|
|
24
25
|
new AntigravityCliAdapter(),
|
|
25
26
|
new OpenCodeAdapter(),
|
|
26
27
|
new PiAdapter(),
|
|
28
|
+
new DevinAdapter(),
|
|
27
29
|
];
|
|
28
30
|
}
|
|
@@ -3,6 +3,7 @@ import { codexReadiness } from "../codex/readiness.js";
|
|
|
3
3
|
import { copilotReadiness } from "../copilot/readiness.js";
|
|
4
4
|
import { openCodeReadiness } from "../opencode/readiness.js";
|
|
5
5
|
import { piReadiness } from "../pi/readiness.js";
|
|
6
|
+
import { devinReadiness } from "../devin/readiness.js";
|
|
6
7
|
import {
|
|
7
8
|
builtInSkillsCheck,
|
|
8
9
|
createReadinessRuntime,
|
|
@@ -29,6 +30,7 @@ const READINESS_PROFILES: Record<ReadinessAgentType, HarnessReadinessProfile> =
|
|
|
29
30
|
opencode: openCodeReadiness,
|
|
30
31
|
copilot: copilotReadiness,
|
|
31
32
|
pi: piReadiness,
|
|
33
|
+
devin: devinReadiness,
|
|
32
34
|
};
|
|
33
35
|
|
|
34
36
|
export async function getAgentReadinessReport(
|
|
@@ -21,6 +21,7 @@ export const HARNESS_RUNTIME_PROFILES: Record<StartableAgentType, HarnessRuntime
|
|
|
21
21
|
opencode: profile("opencode", matchArgv0("opencode")),
|
|
22
22
|
pi: profile("pi", matchAnyBasename(["pi"])),
|
|
23
23
|
kiro: profile("kiro", matchAnyBasename(["kiro-cli", "kiro"]), ["kiro_cli", "kiro-cli"]),
|
|
24
|
+
devin: profile("devin", matchArgv0("devin")),
|
|
24
25
|
};
|
|
25
26
|
|
|
26
27
|
export function runtimeAgentMatchesHarness(runtimeAgent: string, type: AgentType): boolean {
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import { terminalBackendFor } from "./backends/index.js";
|
|
1
|
+
import { TERMINAL_BACKENDS, terminalBackendFor } from "./backends/index.js";
|
|
2
2
|
import type { TerminalLocation } from "./types.js";
|
|
3
3
|
|
|
4
|
+
const SUPPORTED = `Supported: ${TERMINAL_BACKENDS.map((backend) => backend.label).join(", ")}.`;
|
|
5
|
+
|
|
4
6
|
export class TtyWriter {
|
|
5
7
|
static async send(location: TerminalLocation, message: string): Promise<void> {
|
|
6
8
|
const backend = terminalBackendFor(location.type);
|
|
7
9
|
if (!backend) {
|
|
8
10
|
throw new Error(
|
|
9
|
-
`Cannot send input: unsupported terminal type "${location.type}".
|
|
10
|
-
"Supported: tmux, WezTerm, iTerm2, Terminal.app.",
|
|
11
|
+
`Cannot send input: unsupported terminal type "${location.type}". ${SUPPORTED}`,
|
|
11
12
|
);
|
|
12
13
|
}
|
|
13
14
|
await backend.send(location, message);
|
|
@@ -17,8 +18,7 @@ export class TtyWriter {
|
|
|
17
18
|
const backend = terminalBackendFor(location.type);
|
|
18
19
|
if (!backend) {
|
|
19
20
|
throw new Error(
|
|
20
|
-
`Cannot send key: unsupported terminal type "${location.type}".
|
|
21
|
-
"Supported: tmux, WezTerm, iTerm2, Terminal.app.",
|
|
21
|
+
`Cannot send key: unsupported terminal type "${location.type}". ${SUPPORTED}`,
|
|
22
22
|
);
|
|
23
23
|
}
|
|
24
24
|
await backend.sendKey(location, key);
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { writeFile } from "fs/promises";
|
|
3
|
+
import { promisify } from "util";
|
|
4
|
+
import { escapeAppleScript } from "../../utils/applescript.js";
|
|
5
|
+
import { getProcessCwdsAsync } from "../../utils/process.js";
|
|
6
|
+
import { TerminalType, type TerminalBackend, type TerminalLocation } from "../types.js";
|
|
7
|
+
import { CARRIAGE_RETURN, isProcessName, sleep } from "./shared.js";
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
/** Separates fields in AppleScript output; never part of an id, title or path. */
|
|
12
|
+
const FIELD_SEPARATOR = "\x1f";
|
|
13
|
+
const MARKER_POLL_ATTEMPTS = 10;
|
|
14
|
+
const MARKER_POLL_INTERVAL_MS = 50;
|
|
15
|
+
|
|
16
|
+
interface GhosttyTerminal {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
workingDirectory: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ProcessRow {
|
|
23
|
+
pid: number;
|
|
24
|
+
ppid: number;
|
|
25
|
+
tty: string;
|
|
26
|
+
comm: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function listProcesses(): Promise<ProcessRow[]> {
|
|
30
|
+
const { stdout } = await execFileAsync("ps", ["-Axo", "pid=,ppid=,tty=,comm="]);
|
|
31
|
+
const rows: ProcessRow[] = [];
|
|
32
|
+
for (const line of stdout.split("\n")) {
|
|
33
|
+
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
|
|
34
|
+
if (match) {
|
|
35
|
+
rows.push({ pid: Number(match[1]), ppid: Number(match[2]), tty: match[3], comm: match[4] });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return rows;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function listTerminals(): Promise<GhosttyTerminal[]> {
|
|
42
|
+
const script = `
|
|
43
|
+
tell application "Ghostty"
|
|
44
|
+
set out to ""
|
|
45
|
+
repeat with t in terminals
|
|
46
|
+
set out to out & (id of t) & (character id 31) & (name of t) & (character id 31) & (working directory of t) & linefeed
|
|
47
|
+
end repeat
|
|
48
|
+
return out
|
|
49
|
+
end tell`;
|
|
50
|
+
const { stdout } = await execFileAsync("osascript", ["-e", script]);
|
|
51
|
+
return stdout
|
|
52
|
+
.split("\n")
|
|
53
|
+
.map((line) => line.split(FIELD_SEPARATOR))
|
|
54
|
+
.filter((fields) => fields.length === 3 && fields[0])
|
|
55
|
+
.map(([id, name, workingDirectory]) => ({ id, name, workingDirectory }));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Run an AppleScript `command` with `t` bound to the located terminal; false if it is gone. */
|
|
59
|
+
async function onTerminal(location: TerminalLocation, command: string): Promise<boolean> {
|
|
60
|
+
const script = `
|
|
61
|
+
tell application "Ghostty"
|
|
62
|
+
set matches to (every terminal whose id is "${escapeAppleScript(location.identifier)}")
|
|
63
|
+
if (count of matches) is 0 then return "not_found"
|
|
64
|
+
set t to item 1 of matches
|
|
65
|
+
${command}
|
|
66
|
+
end tell
|
|
67
|
+
return "ok"`;
|
|
68
|
+
const { stdout } = await execFileAsync("osascript", ["-e", script]);
|
|
69
|
+
return stdout.trim() === "ok";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Set the window title of whatever terminal owns `tty` (OSC 2). */
|
|
73
|
+
async function writeTitle(tty: string, title: string): Promise<void> {
|
|
74
|
+
// eslint-disable-next-line no-control-regex
|
|
75
|
+
const safe = title.replace(/[\x00-\x1f\x7f]/g, "");
|
|
76
|
+
await writeFile(tty, `\x1b]2;${safe}\x07`, { flag: "a" });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Ghostty's AppleScript terminals expose an id, title and working directory,
|
|
81
|
+
* but not their TTY. Find the one owning `tty` without guessing:
|
|
82
|
+
* 1. only TTYs whose session was started by a ghostty process qualify;
|
|
83
|
+
* 2. if exactly one terminal's working directory matches a process on this
|
|
84
|
+
* TTY, and no other Ghostty TTY has a process there, that is it;
|
|
85
|
+
* 3. otherwise briefly set a unique title on the TTY, find the terminal now
|
|
86
|
+
* showing it, and restore its previous title.
|
|
87
|
+
*/
|
|
88
|
+
async function findTerminalId(tty: string): Promise<string | null> {
|
|
89
|
+
const processes = await listProcesses();
|
|
90
|
+
const ghosttyPids = new Set(
|
|
91
|
+
processes.filter((p) => isProcessName(p.comm, "ghostty")).map((p) => p.pid),
|
|
92
|
+
);
|
|
93
|
+
if (ghosttyPids.size === 0) return null;
|
|
94
|
+
|
|
95
|
+
const ttyName = tty.replace(/^\/dev\//, "");
|
|
96
|
+
const ghosttyTtys = new Set(
|
|
97
|
+
processes.filter((p) => ghosttyPids.has(p.ppid) && p.tty !== "??").map((p) => p.tty),
|
|
98
|
+
);
|
|
99
|
+
if (!ghosttyTtys.has(ttyName)) return null;
|
|
100
|
+
|
|
101
|
+
const terminals = await listTerminals();
|
|
102
|
+
const sessionProcesses = processes.filter((p) => ghosttyTtys.has(p.tty));
|
|
103
|
+
const cwdByPid = await getProcessCwdsAsync(sessionProcesses.map((p) => p.pid));
|
|
104
|
+
const ownDirs = new Set<string>();
|
|
105
|
+
const otherDirs = new Set<string>();
|
|
106
|
+
for (const p of sessionProcesses) {
|
|
107
|
+
const cwd = cwdByPid.get(p.pid);
|
|
108
|
+
if (cwd) (p.tty === ttyName ? ownDirs : otherDirs).add(cwd);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Trust the directory only when exactly one terminal is in a folder no other Ghostty TTY uses.
|
|
112
|
+
const byDirectory = terminals.filter((t) => ownDirs.has(t.workingDirectory));
|
|
113
|
+
if (byDirectory.length === 1 && !otherDirs.has(byDirectory[0].workingDirectory)) {
|
|
114
|
+
return byDirectory[0].id;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const marker = `ai-devkit-${process.pid}-${Date.now()}`;
|
|
118
|
+
await writeTitle(tty, marker);
|
|
119
|
+
for (let attempt = 0; attempt < MARKER_POLL_ATTEMPTS; attempt++) {
|
|
120
|
+
const marked = (await listTerminals()).find((t) => t.name === marker);
|
|
121
|
+
if (marked) {
|
|
122
|
+
const previous = terminals.find((t) => t.id === marked.id)?.name ?? "";
|
|
123
|
+
await writeTitle(tty, previous);
|
|
124
|
+
return marked.id;
|
|
125
|
+
}
|
|
126
|
+
await sleep(MARKER_POLL_INTERVAL_MS);
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A Ghostty `text:` action that writes `bytes` to the terminal as typed.
|
|
133
|
+
* Unlike `send key`, it does not go through the keyboard layout or input
|
|
134
|
+
* method, which can turn a digit key into another character.
|
|
135
|
+
*/
|
|
136
|
+
function textAction(bytes: string): string {
|
|
137
|
+
const escaped = Array.from(bytes, (char) => {
|
|
138
|
+
if (char === "\\") return "\\\\";
|
|
139
|
+
const code = char.charCodeAt(0);
|
|
140
|
+
return code >= 0x20 && code < 0x7f ? char : `\\x${code.toString(16).padStart(2, "0")}`;
|
|
141
|
+
}).join("");
|
|
142
|
+
return `perform action "${escapeAppleScript(`text:${escaped}`)}" on t`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const ghosttyBackend: TerminalBackend = {
|
|
146
|
+
type: TerminalType.GHOSTTY,
|
|
147
|
+
label: "Ghostty",
|
|
148
|
+
|
|
149
|
+
async find(tty) {
|
|
150
|
+
try {
|
|
151
|
+
const id = await findTerminalId(tty);
|
|
152
|
+
return id ? { type: TerminalType.GHOSTTY, identifier: id, tty } : null;
|
|
153
|
+
} catch {
|
|
154
|
+
// Ghostty not installed, not scriptable, or the TTY is not writable
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
async focus(location) {
|
|
160
|
+
return onTerminal(location, "focus t\n activate");
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
async send(location, message) {
|
|
164
|
+
if (!(await onTerminal(location, `input text "${escapeAppleScript(message)}" to t`))) {
|
|
165
|
+
throw new Error(`Ghostty terminal not found for TTY ${location.tty}`);
|
|
166
|
+
}
|
|
167
|
+
await sleep(150);
|
|
168
|
+
if (!(await onTerminal(location, textAction(CARRIAGE_RETURN)))) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`Ghostty terminal disappeared before Enter could be sent for TTY ${location.tty}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
async sendKey(location, key) {
|
|
176
|
+
if (!(await onTerminal(location, textAction(key)))) {
|
|
177
|
+
throw new Error(`Ghostty terminal not found for TTY ${location.tty}`);
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { TerminalBackend } from "../types.js";
|
|
2
2
|
import { TerminalType } from "../types.js";
|
|
3
|
+
import { ghosttyBackend } from "./ghostty.js";
|
|
3
4
|
import { iterm2Backend } from "./iterm2.js";
|
|
4
5
|
import { terminalAppBackend } from "./terminal-app.js";
|
|
5
6
|
import { tmuxBackend } from "./tmux.js";
|
|
@@ -8,6 +9,7 @@ import { weztermBackend } from "./wezterm.js";
|
|
|
8
9
|
export const TERMINAL_BACKENDS: readonly TerminalBackend[] = [
|
|
9
10
|
tmuxBackend,
|
|
10
11
|
weztermBackend,
|
|
12
|
+
ghosttyBackend,
|
|
11
13
|
iterm2Backend,
|
|
12
14
|
terminalAppBackend,
|
|
13
15
|
];
|
|
@@ -33,10 +33,15 @@ export function appleScriptKeyAction(key: string): string {
|
|
|
33
33
|
return `keystroke "${escapeAppleScript(key)}"`;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** Whether a `ps` comm value (bare name or full executable path) is `name`. */
|
|
37
|
+
export function isProcessName(comm: string, name: string): boolean {
|
|
38
|
+
return comm === name || comm.endsWith(`/${name}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
36
41
|
export async function isProcessRunning(name: string): Promise<boolean> {
|
|
37
42
|
const { stdout } = await execFileAsync("ps", ["-Axo", "comm"]);
|
|
38
43
|
return stdout
|
|
39
44
|
.split("\n")
|
|
40
45
|
.map((line) => line.trim())
|
|
41
|
-
.some((command) => command
|
|
46
|
+
.some((command) => isProcessName(command, name));
|
|
42
47
|
}
|
package/src/terminal/types.ts
CHANGED
package/src/utils/process.ts
CHANGED
|
@@ -221,6 +221,11 @@ function parseProcessList(output: string, namePatterns: ReadonlySet<string>): Pa
|
|
|
221
221
|
return { allPids, matched };
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
/** Working directories of `pids` (async `lsof`); PIDs it cannot read are omitted. */
|
|
225
|
+
export function getProcessCwdsAsync(pids: number[]): Promise<Map<number, string>> {
|
|
226
|
+
return batchGetProcessCwdsAsync(execFileText, pids);
|
|
227
|
+
}
|
|
228
|
+
|
|
224
229
|
async function batchGetProcessCwdsAsync(
|
|
225
230
|
exec: ProcessExec,
|
|
226
231
|
pids: number[],
|