@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.
Files changed (94) hide show
  1. package/dist/__tests__/harnesses/devin/DevinAdapter.test.js +597 -0
  2. package/dist/__tests__/harnesses/devin/DevinAdapter.test.js.map +1 -0
  3. package/dist/__tests__/harnesses/devin/DevinAgentMapper.test.js +91 -0
  4. package/dist/__tests__/harnesses/devin/DevinAgentMapper.test.js.map +1 -0
  5. package/dist/__tests__/harnesses/devin/DevinSessionLocator.test.js +192 -0
  6. package/dist/__tests__/harnesses/devin/DevinSessionLocator.test.js.map +1 -0
  7. package/dist/__tests__/harnesses/devin/DevinSessionParser.test.js +407 -0
  8. package/dist/__tests__/harnesses/devin/DevinSessionParser.test.js.map +1 -0
  9. package/dist/__tests__/harnesses/devin/fixtures.js +95 -0
  10. package/dist/__tests__/harnesses/devin/fixtures.js.map +1 -0
  11. package/dist/__tests__/harnesses/readiness/AgentReadiness.test.js +64 -1
  12. package/dist/__tests__/harnesses/readiness/AgentReadiness.test.js.map +1 -1
  13. package/dist/__tests__/terminal/GhosttyBackend.test.js +334 -0
  14. package/dist/__tests__/terminal/GhosttyBackend.test.js.map +1 -0
  15. package/dist/adapters/AgentAdapter.d.ts +1 -1
  16. package/dist/adapters/AgentAdapter.d.ts.map +1 -1
  17. package/dist/adapters/AgentAdapter.js +2 -1
  18. package/dist/adapters/AgentAdapter.js.map +1 -1
  19. package/dist/harnesses/devin/DevinAdapter.d.ts +39 -0
  20. package/dist/harnesses/devin/DevinAdapter.d.ts.map +1 -0
  21. package/dist/harnesses/devin/DevinAdapter.js +177 -0
  22. package/dist/harnesses/devin/DevinAdapter.js.map +1 -0
  23. package/dist/harnesses/devin/DevinAgentMapper.d.ts +11 -0
  24. package/dist/harnesses/devin/DevinAgentMapper.d.ts.map +1 -0
  25. package/dist/harnesses/devin/DevinAgentMapper.js +35 -0
  26. package/dist/harnesses/devin/DevinAgentMapper.js.map +1 -0
  27. package/dist/harnesses/devin/DevinSessionLocator.d.ts +46 -0
  28. package/dist/harnesses/devin/DevinSessionLocator.d.ts.map +1 -0
  29. package/dist/harnesses/devin/DevinSessionLocator.js +176 -0
  30. package/dist/harnesses/devin/DevinSessionLocator.js.map +1 -0
  31. package/dist/harnesses/devin/DevinSessionParser.d.ts +29 -0
  32. package/dist/harnesses/devin/DevinSessionParser.d.ts.map +1 -0
  33. package/dist/harnesses/devin/DevinSessionParser.js +166 -0
  34. package/dist/harnesses/devin/DevinSessionParser.js.map +1 -0
  35. package/dist/harnesses/devin/readiness.d.ts +3 -0
  36. package/dist/harnesses/devin/readiness.d.ts.map +1 -0
  37. package/dist/harnesses/devin/readiness.js +39 -0
  38. package/dist/harnesses/devin/readiness.js.map +1 -0
  39. package/dist/harnesses/index.d.ts.map +1 -1
  40. package/dist/harnesses/index.js +3 -1
  41. package/dist/harnesses/index.js.map +1 -1
  42. package/dist/harnesses/readiness/AgentReadiness.d.ts.map +1 -1
  43. package/dist/harnesses/readiness/AgentReadiness.js +3 -1
  44. package/dist/harnesses/readiness/AgentReadiness.js.map +1 -1
  45. package/dist/harnesses/readiness/types.d.ts +1 -1
  46. package/dist/harnesses/readiness/types.d.ts.map +1 -1
  47. package/dist/harnesses/runtimeProfiles.d.ts.map +1 -1
  48. package/dist/harnesses/runtimeProfiles.js +2 -1
  49. package/dist/harnesses/runtimeProfiles.js.map +1 -1
  50. package/dist/terminal/TtyWriter.d.ts.map +1 -1
  51. package/dist/terminal/TtyWriter.js +4 -3
  52. package/dist/terminal/TtyWriter.js.map +1 -1
  53. package/dist/terminal/backends/ghostty.d.ts +3 -0
  54. package/dist/terminal/backends/ghostty.d.ts.map +1 -0
  55. package/dist/terminal/backends/ghostty.js +161 -0
  56. package/dist/terminal/backends/ghostty.js.map +1 -0
  57. package/dist/terminal/backends/index.d.ts.map +1 -1
  58. package/dist/terminal/backends/index.js +2 -0
  59. package/dist/terminal/backends/index.js.map +1 -1
  60. package/dist/terminal/backends/shared.d.ts +2 -0
  61. package/dist/terminal/backends/shared.d.ts.map +1 -1
  62. package/dist/terminal/backends/shared.js +4 -1
  63. package/dist/terminal/backends/shared.js.map +1 -1
  64. package/dist/terminal/types.d.ts +1 -0
  65. package/dist/terminal/types.d.ts.map +1 -1
  66. package/dist/terminal/types.js +1 -0
  67. package/dist/terminal/types.js.map +1 -1
  68. package/dist/utils/process.d.ts +2 -0
  69. package/dist/utils/process.d.ts.map +1 -1
  70. package/dist/utils/process.js +3 -0
  71. package/dist/utils/process.js.map +1 -1
  72. package/package.json +1 -1
  73. package/src/__tests__/harnesses/devin/DevinAdapter.test.ts +292 -0
  74. package/src/__tests__/harnesses/devin/DevinAgentMapper.test.ts +88 -0
  75. package/src/__tests__/harnesses/devin/DevinSessionLocator.test.ts +139 -0
  76. package/src/__tests__/harnesses/devin/DevinSessionParser.test.ts +218 -0
  77. package/src/__tests__/harnesses/devin/fixtures.ts +144 -0
  78. package/src/__tests__/harnesses/readiness/AgentReadiness.test.ts +41 -0
  79. package/src/__tests__/terminal/GhosttyBackend.test.ts +271 -0
  80. package/src/adapters/AgentAdapter.ts +1 -0
  81. package/src/harnesses/devin/DevinAdapter.ts +224 -0
  82. package/src/harnesses/devin/DevinAgentMapper.ts +43 -0
  83. package/src/harnesses/devin/DevinSessionLocator.ts +230 -0
  84. package/src/harnesses/devin/DevinSessionParser.ts +236 -0
  85. package/src/harnesses/devin/readiness.ts +36 -0
  86. package/src/harnesses/index.ts +2 -0
  87. package/src/harnesses/readiness/AgentReadiness.ts +2 -0
  88. package/src/harnesses/runtimeProfiles.ts +1 -0
  89. package/src/terminal/TtyWriter.ts +5 -5
  90. package/src/terminal/backends/ghostty.ts +180 -0
  91. package/src/terminal/backends/index.ts +2 -0
  92. package/src/terminal/backends/shared.ts +6 -1
  93. package/src/terminal/types.ts +1 -0
  94. 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
+ };
@@ -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 === name || command.endsWith(`/${name}`));
46
+ .some((command) => isProcessName(command, name));
42
47
  }
@@ -1,6 +1,7 @@
1
1
  export enum TerminalType {
2
2
  TMUX = "tmux",
3
3
  WEZTERM = "wezterm",
4
+ GHOSTTY = "ghostty",
4
5
  ITERM2 = "iterm2",
5
6
  TERMINAL_APP = "terminal-app",
6
7
  UNKNOWN = "unknown",
@@ -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[],