termdock 1.3.0 → 1.4.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 (86) hide show
  1. package/dist/client/assets/{abnfDiagram-VRR7QNED-DboLYKjC.js → abnfDiagram-VRR7QNED-G_YxqZwv.js} +1 -1
  2. package/dist/client/assets/{arc-C7cI3SGK.js → arc-BuvKiJCC.js} +1 -1
  3. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-DupwL8qH.js → architectureDiagram-ZJ3FMSHR-BvkwEVZw.js} +1 -1
  4. package/dist/client/assets/{blockDiagram-677ZJIJ3-DNM7fUPm.js → blockDiagram-677ZJIJ3-C3bxox0Z.js} +1 -1
  5. package/dist/client/assets/{c4Diagram-LMCZKHZV-Jaxf-YGr.js → c4Diagram-LMCZKHZV-DLORcr8m.js} +1 -1
  6. package/dist/client/assets/channel-BnGIwxUi.js +1 -0
  7. package/dist/client/assets/{chunk-2Q5K7J3B-CjqqaH_Z.js → chunk-2Q5K7J3B-Dnqi-8g0.js} +1 -1
  8. package/dist/client/assets/{chunk-32BRIVSS-BYtt3b30.js → chunk-32BRIVSS-D70B4HxL.js} +1 -1
  9. package/dist/client/assets/{chunk-5VM5RSS4-C68y4tT7.js → chunk-5VM5RSS4-Cl7t39aR.js} +1 -1
  10. package/dist/client/assets/{chunk-EX3LRPZG-BpJMVcVA.js → chunk-EX3LRPZG-DKMD4DfE.js} +1 -1
  11. package/dist/client/assets/{chunk-JWPE2WC7-BaYNrMI4.js → chunk-JWPE2WC7-CsN_pMOw.js} +1 -1
  12. package/dist/client/assets/{chunk-MOJQB5TN-y5QTOrLI.js → chunk-MOJQB5TN-D8BHaQpE.js} +1 -1
  13. package/dist/client/assets/{chunk-RYQCIY6F-DiHf2o1T.js → chunk-RYQCIY6F-B8BtUiHF.js} +1 -1
  14. package/dist/client/assets/{chunk-V7JOEXUC-9j4f9aIn.js → chunk-V7JOEXUC-CdN7tlvn.js} +1 -1
  15. package/dist/client/assets/{chunk-VR4S4FIN-CdbelzcZ.js → chunk-VR4S4FIN-UPjL_wl4.js} +1 -1
  16. package/dist/client/assets/{chunk-XXDRQBXY-YAjZkdyb.js → chunk-XXDRQBXY--eX5Na_l.js} +1 -1
  17. package/dist/client/assets/classDiagram-OUVF2IWQ-DMigd9mj.js +1 -0
  18. package/dist/client/assets/classDiagram-v2-EOCWNBFH-DMigd9mj.js +1 -0
  19. package/dist/client/assets/{cose-bilkent-JH36ORCC-lTBDAWfs.js → cose-bilkent-JH36ORCC-CEAcZx3N.js} +1 -1
  20. package/dist/client/assets/{cynefin-VYW2F7L2-Dl7ev6m4.js → cynefin-VYW2F7L2-up8zIMDo.js} +1 -1
  21. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-CSxz6O5q.js → cynefinDiagram-TSTJHNR4-DZ2SQpCq.js} +1 -1
  22. package/dist/client/assets/{dagre-VKFMJZFB-zWgEHxrl.js → dagre-VKFMJZFB-DOoLtq9O.js} +1 -1
  23. package/dist/client/assets/{diagram-FQU43EPY-BQeiowzD.js → diagram-FQU43EPY-x8umATxt.js} +1 -1
  24. package/dist/client/assets/{diagram-G47NLZAW-DJezXuHM.js → diagram-G47NLZAW-Kle7Toxb.js} +1 -1
  25. package/dist/client/assets/{diagram-NH7WQ7WH-D1d1WkdQ.js → diagram-NH7WQ7WH-CezkkMqP.js} +1 -1
  26. package/dist/client/assets/{diagram-OA4YK3LP-C2ZpnAsd.js → diagram-OA4YK3LP-CaU0u6UQ.js} +1 -1
  27. package/dist/client/assets/{diagram-WEI45ONY-DH9kmAm9.js → diagram-WEI45ONY-B4m53TVj.js} +1 -1
  28. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-CjnajZxN.js → ebnfDiagram-CCIWWBDH-Dpwjo8_v.js} +1 -1
  29. package/dist/client/assets/{erDiagram-Q63AITRT-CzmBQFbx.js → erDiagram-Q63AITRT-BU4R1QVk.js} +1 -1
  30. package/dist/client/assets/{flowDiagram-23GEKE2U-ljeoJhTo.js → flowDiagram-23GEKE2U-BFtDsA0k.js} +1 -1
  31. package/dist/client/assets/{ganttDiagram-NO4QXBWP-DF3m0dot.js → ganttDiagram-NO4QXBWP-DtSEfub5.js} +1 -1
  32. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-BwB7r571.js → gitGraphDiagram-IHSO6WYX-BGukYlT3.js} +1 -1
  33. package/dist/client/assets/index-CP3_puZd.js +395 -0
  34. package/dist/client/assets/index-CxBkmASY.css +1 -0
  35. package/dist/client/assets/{infoDiagram-FWYZ7A6U-Cv9S0ijK.js → infoDiagram-FWYZ7A6U-Bx1NQNsL.js} +1 -1
  36. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-DRecz33b.js → ishikawaDiagram-FXEZZL3T-DHlxYJjl.js} +1 -1
  37. package/dist/client/assets/{journeyDiagram-5HDEW3XC-BLRRylnR.js → journeyDiagram-5HDEW3XC-BOZzlR6d.js} +1 -1
  38. package/dist/client/assets/{kanban-definition-HUTT4EX6-BC1ph5cw.js → kanban-definition-HUTT4EX6-C2nWuUXX.js} +1 -1
  39. package/dist/client/assets/{linear-C2zeZj0z.js → linear-DtblW0g4.js} +1 -1
  40. package/dist/client/assets/{mermaid.core-BqDN_zP4.js → mermaid.core-BKSuG721.js} +4 -4
  41. package/dist/client/assets/{mindmap-definition-LN4V7U3C-BpDX-24D.js → mindmap-definition-LN4V7U3C-Co3LLQGt.js} +1 -1
  42. package/dist/client/assets/{pegDiagram-2B236MQR-Sn0Dke6L.js → pegDiagram-2B236MQR-DSUG1nB1.js} +1 -1
  43. package/dist/client/assets/{pieDiagram-ENE6RG2P-DzMhYGWJ.js → pieDiagram-ENE6RG2P-DvEr0UPf.js} +1 -1
  44. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-CuQoPglS.js → quadrantDiagram-ABIIQ3AL-C2diLBZ8.js} +1 -1
  45. package/dist/client/assets/{railroadDiagram-RFXS5EU6-C4hKErDp.js → railroadDiagram-RFXS5EU6-CqYZHCvf.js} +1 -1
  46. package/dist/client/assets/{requirementDiagram-TGXJPOKE-CE5J97_g.js → requirementDiagram-TGXJPOKE-CFAKdPQp.js} +1 -1
  47. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-BDyhQ6qz.js → sankeyDiagram-HTMAVEWB-DV95NA9H.js} +1 -1
  48. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-CW-oi8so.js → sequenceDiagram-DBY2YBRQ-cbv0xzG-.js} +1 -1
  49. package/dist/client/assets/{sizeCapture-X5ZJPWSS-mMoYKQSz.js → sizeCapture-X5ZJPWSS-DpkzYB1F.js} +1 -1
  50. package/dist/client/assets/{stateDiagram-2N3HPSRC-BUd0G8S2.js → stateDiagram-2N3HPSRC-BnAwPP0V.js} +1 -1
  51. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DtPm_oHq.js +1 -0
  52. package/dist/client/assets/{swimlanes-5IMT3BWC-B8hw4xPM.js → swimlanes-5IMT3BWC-kObRIKfj.js} +2 -2
  53. package/dist/client/assets/swimlanesDiagram-G3AALYLV-yeb4EUGj.js +8 -0
  54. package/dist/client/assets/{timeline-definition-FHXFAJF6-DykkYG1G.js → timeline-definition-FHXFAJF6-BbnAJ8pO.js} +1 -1
  55. package/dist/client/assets/{vennDiagram-L72KCM5P-BBkpVj6b.js → vennDiagram-L72KCM5P-sIu_hWKx.js} +1 -1
  56. package/dist/client/assets/{wardleyDiagram-EHGQE667-ClqSnTks.js → wardleyDiagram-EHGQE667-DpGdJtdb.js} +1 -1
  57. package/dist/client/assets/{xychartDiagram-FW5EYKEG-Ba_5tcTW.js → xychartDiagram-FW5EYKEG-C5F9faN2.js} +1 -1
  58. package/dist/client/icons/agents/amp.svg +12 -0
  59. package/dist/client/icons/agents/claude.svg +10 -0
  60. package/dist/client/icons/agents/codex.svg +10 -0
  61. package/dist/client/icons/agents/copilot.svg +12 -0
  62. package/dist/client/icons/agents/cursor.svg +3 -0
  63. package/dist/client/icons/agents/droid.svg +10 -0
  64. package/dist/client/icons/agents/gemini.svg +5 -0
  65. package/dist/client/icons/agents/goose.svg +10 -0
  66. package/dist/client/icons/agents/grok.svg +5 -0
  67. package/dist/client/icons/agents/opencode.svg +3 -0
  68. package/dist/client/index.html +2 -2
  69. package/dist/client/sw.js +1 -1
  70. package/dist/server/agent/installers.js +551 -0
  71. package/dist/server/agent/registry.js +244 -0
  72. package/dist/server/agent/session.js +162 -0
  73. package/dist/server/agentHook.js +174 -0
  74. package/dist/server/ptyHost.js +261 -0
  75. package/dist/server/ptyhost/manager.js +396 -0
  76. package/dist/server/ptyhost/protocol.js +80 -0
  77. package/dist/server/routes/terminal.js +829 -395
  78. package/dist/server/utils/gitStatus.js +114 -0
  79. package/package.json +1 -1
  80. package/dist/client/assets/channel-D1achAhM.js +0 -1
  81. package/dist/client/assets/classDiagram-OUVF2IWQ-BiqbrzEM.js +0 -1
  82. package/dist/client/assets/classDiagram-v2-EOCWNBFH-BiqbrzEM.js +0 -1
  83. package/dist/client/assets/index-QtbZAkiJ.css +0 -1
  84. package/dist/client/assets/index-odB6evg3.js +0 -394
  85. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-XLkUpRKa.js +0 -1
  86. package/dist/client/assets/swimlanesDiagram-G3AALYLV-CA2gJRr9.js +0 -8
@@ -0,0 +1,551 @@
1
+ /**
2
+ * Agent-side hook installers: wire `agentHook.js` into each CLI agent's own
3
+ * hook surface. (设计移植自 tty7 core/agent_hooks.rs,Apache-2.0;TS 重写)
4
+ *
5
+ * Each supported agent exposes hooks differently — Claude Code and Codex take
6
+ * a declarative hooks map, Copilot and Grok auto-load JSON hook files from a
7
+ * directory, OpenCode loads JS plugins, Pi loads TS extensions — but every
8
+ * integration bottoms out in the same emitter: `agentHook.js agent-hook
9
+ * <agent> <event>` reads the hook's JSON payload from stdin and writes one
10
+ * sentinel OSC 777 sequence to the controlling terminal.
11
+ *
12
+ * All installers are idempotent (marker-carrying entries are rewritten in
13
+ * place, never duplicated), ownership-guarded (user-authored hooks/files are
14
+ * never touched), and report NotInstalled / Installed / Outdated.
15
+ */
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import { execFile } from 'node:child_process';
20
+ import { agentBySlug } from './registry.js';
21
+ export const HOOK_AGENTS = ['claude', 'codex', 'copilot', 'opencode', 'pi', 'grok'];
22
+ const DISPLAY_NAMES = {
23
+ claude: 'Claude Code',
24
+ codex: 'Codex',
25
+ copilot: 'Copilot CLI',
26
+ opencode: 'OpenCode',
27
+ pi: 'Pi',
28
+ grok: 'Grok Build',
29
+ };
30
+ // ---------------------------------------------------------------------------
31
+ // Paths and the hook command line
32
+ // ---------------------------------------------------------------------------
33
+ function homeDir() {
34
+ return os.homedir();
35
+ }
36
+ /** Claude Code's user settings file: $CLAUDE_CONFIG_DIR/settings.json. */
37
+ function claudeSettingsPath() {
38
+ const dir = process.env.CLAUDE_CONFIG_DIR;
39
+ if (dir && dir.trim().length > 0)
40
+ return path.join(dir, 'settings.json');
41
+ return path.join(homeDir(), '.claude', 'settings.json');
42
+ }
43
+ /** $XDG_CONFIG_HOME, defaulting to ~/.config (OpenCode's config root). */
44
+ function xdgConfigDir() {
45
+ const dir = process.env.XDG_CONFIG_HOME;
46
+ if (dir && dir.trim().length > 0)
47
+ return dir;
48
+ return path.join(homeDir(), '.config');
49
+ }
50
+ const OWNED_FILE_STEM_JSON = 'termdock.json';
51
+ const OWNED_FILE_STEM_JS = 'termdock.js';
52
+ function targetPath(agent) {
53
+ switch (agent) {
54
+ case 'claude': return claudeSettingsPath();
55
+ case 'codex': return path.join(homeDir(), '.codex', 'hooks.json');
56
+ case 'copilot': return path.join(homeDir(), '.copilot', 'hooks', OWNED_FILE_STEM_JSON);
57
+ case 'opencode': return path.join(xdgConfigDir(), 'opencode', 'plugins', OWNED_FILE_STEM_JS);
58
+ case 'pi': return path.join(homeDir(), '.pi', 'agent', 'extensions', 'termdock', 'index.ts');
59
+ case 'grok': return path.join(homeDir(), '.grok', 'hooks', OWNED_FILE_STEM_JSON);
60
+ }
61
+ }
62
+ function abbreviateHome(p) {
63
+ const home = homeDir();
64
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
65
+ }
66
+ /**
67
+ * Absolute path of the built emitter script. In a built install this is
68
+ * `dist/server/agentHook.js`; under tsx dev only the .ts source exists —
69
+ * hooks then point at a file plain node can't run, so `installHooks` reports
70
+ * `devMode` for the UI to warn about.
71
+ */
72
+ export function resolveHookScript() {
73
+ const here = path.dirname(new URL(import.meta.url).pathname);
74
+ const js = path.resolve(here, '..', 'agentHook.js');
75
+ if (fs.existsSync(js))
76
+ return { script: js, devMode: false };
77
+ const ts = path.resolve(here, '..', 'agentHook.ts');
78
+ return { script: ts, devMode: true };
79
+ }
80
+ /**
81
+ * The hook command line written into an agent's config — node by absolute
82
+ * path (works regardless of PATH) + the emitter script + the literal
83
+ * `agent-hook <slug>` marker token. Quoted because paths can carry spaces.
84
+ */
85
+ function hookCommand(agent, event) {
86
+ const { script } = resolveHookScript();
87
+ return `"${process.execPath}" "${script}" agent-hook ${agent} ${event}`;
88
+ }
89
+ /** Substring identifying a hook entry / owned file as termdock's. Every
90
+ * generated command and file embeds `agent-hook <slug>` verbatim. */
91
+ function marker(agent) {
92
+ return `agent-hook ${agent}`;
93
+ }
94
+ /** Best-effort atomic write: tmp file + rename. */
95
+ function writeAtomic(file, content) {
96
+ fs.mkdirSync(path.dirname(file), { recursive: true });
97
+ const tmp = `${file}.tmp-${process.pid}`;
98
+ fs.writeFileSync(tmp, content, 'utf8');
99
+ fs.renameSync(tmp, file);
100
+ }
101
+ // ---------------------------------------------------------------------------
102
+ // Hooks-map installer (Claude Code, Codex): a JSON object with a top-level
103
+ // "hooks" key mapping event names to entry lists; termdock owns exactly one
104
+ // marker-carrying entry per event and never touches the rest of the file.
105
+ // ---------------------------------------------------------------------------
106
+ /**
107
+ * Claude Code's hook events and the sentinel event each maps onto.
108
+ * `Notification` covers both "needs permission" and "waiting for input" —
109
+ * exactly the waiting state. `PostToolUse` is the way *back*: Claude has no
110
+ * "permission replied" hook, so the first tool that completes after the user
111
+ * approves is the signal that the turn is moving again.
112
+ */
113
+ const CLAUDE_HOOK_EVENTS = [
114
+ ['SessionStart', 'session-start'],
115
+ ['UserPromptSubmit', 'prompt-submit'],
116
+ ['Notification', 'notification'],
117
+ ['PostToolUse', 'tool-complete'],
118
+ ['Stop', 'stop'],
119
+ ['SessionEnd', 'session-end'],
120
+ ];
121
+ /** Codex's hook events (~/.codex/hooks.json, Claude-shaped). Codex is
122
+ * turn-level only: no Notification hook, and no SessionEnd — the pane's
123
+ * foreground detection clears the badge when Codex exits. */
124
+ const CODEX_HOOK_EVENTS = [
125
+ ['SessionStart', 'session-start'],
126
+ ['UserPromptSubmit', 'prompt-submit'],
127
+ ['Stop', 'stop'],
128
+ ];
129
+ /**
130
+ * Grok Build's hook events — Claude Code's vocabulary (grok mirrors it
131
+ * deliberately, it even reads ~/.claude/settings.json) — plus the matcher
132
+ * each subscription is narrowed by. `Notification` is the one narrowed
133
+ * subscription: grok dispatches its `permission_prompt` notification *before*
134
+ * the permission system decides, so it fires on essentially every tool call —
135
+ * escalating that to the "needs you" state would flash the pane on every tool
136
+ * a turn runs. `elicitation_dialog` — grok's ask-the-user question — is the
137
+ * type that always means a real block.
138
+ */
139
+ const GROK_HOOK_EVENTS = [
140
+ ['SessionStart', 'session-start', null],
141
+ ['UserPromptSubmit', 'prompt-submit', null],
142
+ ['Notification', 'notification', 'elicitation_dialog'],
143
+ ['PostToolUse', 'tool-complete', null],
144
+ ['Stop', 'stop', null],
145
+ ['SessionEnd', 'session-end', null],
146
+ ];
147
+ /** Seconds grok gives one termdock hook before killing it (its default is
148
+ * 600s — a budget for hooks that run test suites; ours writes a few bytes). */
149
+ const GROK_HOOK_TIMEOUT_SECS = 10;
150
+ function readJsonObject(file) {
151
+ let text;
152
+ try {
153
+ text = fs.readFileSync(file, 'utf8');
154
+ }
155
+ catch {
156
+ return null;
157
+ }
158
+ try {
159
+ const parsed = JSON.parse(text);
160
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
161
+ ? parsed
162
+ : null;
163
+ }
164
+ catch {
165
+ return null;
166
+ }
167
+ }
168
+ /** The termdock hook command inside one matcher entry
169
+ * (`{"matcher": …, "hooks": [{"command": …}]}`), if it carries one. */
170
+ function markerCommand(entry, mark) {
171
+ if (!entry || typeof entry !== 'object')
172
+ return null;
173
+ const hooks = entry.hooks;
174
+ if (!Array.isArray(hooks))
175
+ return null;
176
+ for (const h of hooks) {
177
+ const cmd = h && typeof h === 'object' ? h.command : null;
178
+ if (typeof cmd === 'string' && cmd.includes(mark))
179
+ return cmd;
180
+ }
181
+ return null;
182
+ }
183
+ function hookMapState(file, agent, events) {
184
+ const root = readJsonObject(file);
185
+ if (!root)
186
+ return 'not-installed';
187
+ const mark = marker(agent);
188
+ const hooks = root.hooks;
189
+ let any = false;
190
+ let complete = true;
191
+ for (const [hookEvent, sentinel] of events) {
192
+ const list = hooks && typeof hooks === 'object'
193
+ ? hooks[hookEvent]
194
+ : null;
195
+ const ours = Array.isArray(list)
196
+ ? list.map((entry) => markerCommand(entry, mark)).find((c) => c !== null) ?? null
197
+ : null;
198
+ if (ours !== null) {
199
+ any = true;
200
+ if (ours !== hookCommand(agent, sentinel))
201
+ complete = false;
202
+ }
203
+ else {
204
+ complete = false;
205
+ }
206
+ }
207
+ if (!any)
208
+ return 'not-installed';
209
+ return complete ? 'installed' : 'outdated';
210
+ }
211
+ /** Merge termdock's hook entries into the file, preserving everything else. */
212
+ function hookMapInstall(file, agent, events) {
213
+ let root = {};
214
+ try {
215
+ const text = fs.readFileSync(file, 'utf8');
216
+ const parsed = JSON.parse(text);
217
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
218
+ throw new Error(`${file} is not a JSON object; not touching it`);
219
+ }
220
+ root = parsed;
221
+ }
222
+ catch (error) {
223
+ if (error.code !== 'ENOENT')
224
+ throw error;
225
+ }
226
+ if (!root.hooks || typeof root.hooks !== 'object' || Array.isArray(root.hooks)) {
227
+ if (root.hooks !== undefined) {
228
+ throw new Error(`"hooks" in ${file} is not an object; not touching it`);
229
+ }
230
+ root.hooks = {};
231
+ }
232
+ const hooks = root.hooks;
233
+ const mark = marker(agent);
234
+ for (const [hookEvent, sentinel] of events) {
235
+ const command = hookCommand(agent, sentinel);
236
+ let list = hooks[hookEvent];
237
+ if (list === undefined) {
238
+ list = [];
239
+ hooks[hookEvent] = list;
240
+ }
241
+ if (!Array.isArray(list))
242
+ continue; // malformed user config; leave it alone
243
+ // Drop any previous termdock entry (stale exe path), then append ours.
244
+ const kept = list.filter((entry) => markerCommand(entry, mark) === null);
245
+ kept.push({ hooks: [{ type: 'command', command }] });
246
+ hooks[hookEvent] = kept;
247
+ }
248
+ writeAtomic(file, JSON.stringify(root, null, 2));
249
+ }
250
+ /** Remove every termdock hook entry, leaving user-defined hooks untouched.
251
+ * Sweeps *all* hook events so entries left by an older termdock with a
252
+ * different event set are cleaned up too. */
253
+ function hookMapUninstall(file, agent) {
254
+ const root = readJsonObject(file);
255
+ if (!root)
256
+ return 'Nothing installed; nothing to remove';
257
+ const mark = marker(agent);
258
+ let removed = 0;
259
+ if (root.hooks && typeof root.hooks === 'object') {
260
+ const hooks = root.hooks;
261
+ for (const eventName of Object.keys(hooks)) {
262
+ const list = hooks[eventName];
263
+ if (!Array.isArray(list))
264
+ continue;
265
+ const kept = list.filter((entry) => markerCommand(entry, mark) === null);
266
+ removed += list.length - kept.length;
267
+ if (kept.length === 0) {
268
+ delete hooks[eventName];
269
+ }
270
+ else {
271
+ hooks[eventName] = kept;
272
+ }
273
+ }
274
+ }
275
+ if (removed === 0)
276
+ return 'No termdock hooks found; nothing to remove';
277
+ writeAtomic(file, JSON.stringify(root, null, 2));
278
+ return 'Removed';
279
+ }
280
+ /** Turn Codex's hooks feature flag on, which gates whether hooks.json is
281
+ * read at all. Best-effort: the file install is complete either way, so a
282
+ * missing codex binary downgrades to advice instead of failing. */
283
+ function enableCodexHooksFeature() {
284
+ return new Promise((resolvePromise, rejectPromise) => {
285
+ const candidates = [
286
+ '/opt/homebrew/bin/codex',
287
+ '/usr/local/bin/codex',
288
+ path.join(homeDir(), '.local', 'bin', 'codex'),
289
+ ];
290
+ const program = candidates.find((p) => fs.existsSync(p)) ?? 'codex';
291
+ execFile(program, ['features', 'enable', 'hooks'], { timeout: 10_000 }, (error, _stdout, stderr) => {
292
+ if (!error)
293
+ return resolvePromise();
294
+ rejectPromise(new Error(`${program}: ${String(stderr).trim() || error.message}`));
295
+ });
296
+ });
297
+ }
298
+ // ---------------------------------------------------------------------------
299
+ // Owned-file installer (Copilot, OpenCode, Pi, Grok): termdock writes a whole
300
+ // file it owns outright, identified by the marker. Install refuses to clobber
301
+ // a file termdock didn't write; uninstall only ever deletes a marker file.
302
+ // ---------------------------------------------------------------------------
303
+ /** Copilot hook file (~/.copilot/hooks/termdock.json): Copilot auto-loads
304
+ * every JSON file in that directory. Event names are Copilot's camelCase
305
+ * vocabulary. `notification` is passed through and filtered in the emitter. */
306
+ function copilotHooksJson() {
307
+ const hook = (event, timeoutSec) => [{
308
+ type: 'command',
309
+ bash: hookCommand('copilot', event),
310
+ timeoutSec,
311
+ }];
312
+ return JSON.stringify({
313
+ version: 1,
314
+ hooks: {
315
+ sessionStart: hook('session-start', 5),
316
+ userPromptSubmitted: hook('prompt-submit', 5),
317
+ agentStop: hook('stop', 10),
318
+ sessionEnd: hook('session-end', 5),
319
+ notification: hook('notification', 5),
320
+ },
321
+ }, null, 2);
322
+ }
323
+ /** Grok Build hook file (~/.grok/hooks/termdock.json). Grok loads every JSON
324
+ * file there and global hooks need no folder-trust grant. Same wiring as
325
+ * CLAUDE_HOOK_EVENTS in owned-file form, with the Notification matcher. */
326
+ function grokHooksJson() {
327
+ const hooks = {};
328
+ for (const [event, sentinel, match] of GROK_HOOK_EVENTS) {
329
+ const group = {
330
+ hooks: [{
331
+ type: 'command',
332
+ command: hookCommand('grok', sentinel),
333
+ timeout: GROK_HOOK_TIMEOUT_SECS,
334
+ }],
335
+ };
336
+ if (match)
337
+ group.matcher = match;
338
+ hooks[event] = [group];
339
+ }
340
+ return JSON.stringify({ hooks }, null, 2);
341
+ }
342
+ /** OpenCode plugin (~/.config/opencode/plugins/termdock.js). OpenCode has no
343
+ * declarative hooks — its extensibility surface is JS plugins auto-loaded
344
+ * from that directory — so the plugin bridges its events onto the same
345
+ * emitter. Inert outside termdock (both the JS guard and the emitter check
346
+ * TERMDOCK). */
347
+ function opencodePluginJs() {
348
+ const prefix = JSON.stringify(`"${process.execPath}" "${resolveHookScript().script}" agent-hook opencode `);
349
+ return `// termdock agent-hook opencode bridge — generated by termdock, do not edit.
350
+ // Bridges OpenCode plugin events onto the termdock agent-hook emitter,
351
+ // which is inert outside termdock (gated on the TERMDOCK env var).
352
+ export const TermdockPresence = async ({ $ }) => {
353
+ if (!process.env["TERMDOCK"]) return {}
354
+ const cmd = ${prefix}
355
+ const emit = (event) => $\`sh -c \${cmd + event}\`.quiet().nothrow()
356
+
357
+ // Plugin load = the agent is running in this pane.
358
+ await emit("session-start")
359
+
360
+ return {
361
+ dispose: async () => {
362
+ await emit("session-end")
363
+ },
364
+ "tool.execute.before": async () => {
365
+ await emit("prompt-submit")
366
+ },
367
+ "permission.ask": async () => {
368
+ await emit("permission-request")
369
+ },
370
+ event: async ({ event }) => {
371
+ if (event.type === "session.idle") {
372
+ await emit("stop")
373
+ } else if (event.type === "permission.replied") {
374
+ await emit("prompt-submit")
375
+ }
376
+ },
377
+ }
378
+ }
379
+ `;
380
+ }
381
+ /** Pi extension (~/.pi/agent/extensions/termdock/index.ts). Pi auto-loads TS
382
+ * extensions from per-directory index.ts files; this one forwards Pi's
383
+ * lifecycle events to the emitter. Inert outside termdock. */
384
+ function piExtensionTs() {
385
+ const script = JSON.stringify(resolveHookScript().script);
386
+ const node = JSON.stringify(process.execPath);
387
+ return `/* termdock agent-hook pi bridge — generated by termdock, do not edit. */
388
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
389
+ import { spawnSync } from "node:child_process";
390
+
391
+ const EXE = ${node};
392
+ const SCRIPT = ${script};
393
+
394
+ function emit(event: string): void {
395
+ try {
396
+ spawnSync(EXE, [SCRIPT, "agent-hook", "pi", event], { stdio: ["ignore", "ignore", "ignore"] });
397
+ } catch {}
398
+ }
399
+
400
+ export default function (pi: ExtensionAPI) {
401
+ if (!process.env["TERMDOCK"]) return;
402
+ // Extension load = the agent is running in this pane; Pi has no separate
403
+ // session-start event.
404
+ emit("session-start");
405
+ pi.on("agent_start", () => emit("prompt-submit"));
406
+ pi.on("agent_end", () => emit("stop"));
407
+ pi.on("session_shutdown", () => emit("session-end"));
408
+ }
409
+ `;
410
+ }
411
+ function ownedFileContent(agent) {
412
+ switch (agent) {
413
+ case 'copilot': return copilotHooksJson();
414
+ case 'opencode': return opencodePluginJs();
415
+ case 'pi': return piExtensionTs();
416
+ case 'grok': return grokHooksJson();
417
+ default: return null;
418
+ }
419
+ }
420
+ function ownedFileState(file, expected, mark) {
421
+ let contents;
422
+ try {
423
+ contents = fs.readFileSync(file, 'utf8');
424
+ }
425
+ catch {
426
+ return 'not-installed';
427
+ }
428
+ if (contents === expected)
429
+ return 'installed';
430
+ // termdock wrote it (the marker survives), but from another binary or an
431
+ // older version of the content.
432
+ if (contents.includes(mark))
433
+ return 'outdated';
434
+ return 'not-installed';
435
+ }
436
+ function ownedFileInstall(file, content, mark) {
437
+ try {
438
+ const existing = fs.readFileSync(file, 'utf8');
439
+ if (!existing.includes(mark)) {
440
+ throw new Error(`${file} exists but wasn't written by termdock; not touching it`);
441
+ }
442
+ }
443
+ catch (error) {
444
+ if (error.code !== 'ENOENT')
445
+ throw error;
446
+ }
447
+ writeAtomic(file, content);
448
+ }
449
+ function ownedFileUninstall(file, mark) {
450
+ let contents;
451
+ try {
452
+ contents = fs.readFileSync(file, 'utf8');
453
+ }
454
+ catch (error) {
455
+ if (error.code === 'ENOENT') {
456
+ return 'Nothing installed; nothing to remove';
457
+ }
458
+ throw error;
459
+ }
460
+ if (!contents.includes(mark)) {
461
+ throw new Error(`${file} wasn't written by termdock; not touching it`);
462
+ }
463
+ fs.unlinkSync(file);
464
+ // Pi's extension lives in its own directory; sweep it if now empty.
465
+ const parent = path.dirname(file);
466
+ if (path.basename(parent) === 'termdock') {
467
+ try {
468
+ fs.rmdirSync(parent);
469
+ }
470
+ catch { /* non-empty dir is the user's */ }
471
+ }
472
+ return 'Removed';
473
+ }
474
+ // ---------------------------------------------------------------------------
475
+ // Public API
476
+ // ---------------------------------------------------------------------------
477
+ export function hooksState(agent) {
478
+ const file = targetPath(agent);
479
+ if (agent === 'claude')
480
+ return hookMapState(file, agent, CLAUDE_HOOK_EVENTS);
481
+ if (agent === 'codex')
482
+ return hookMapState(file, agent, CODEX_HOOK_EVENTS);
483
+ const expected = ownedFileContent(agent);
484
+ if (expected === null)
485
+ return 'not-installed';
486
+ return ownedFileState(file, expected, marker(agent));
487
+ }
488
+ export function listHookAgents() {
489
+ return HOOK_AGENTS.map((slug) => ({
490
+ slug,
491
+ displayName: DISPLAY_NAMES[slug],
492
+ targetDisplay: abbreviateHome(targetPath(slug)),
493
+ state: hooksState(slug),
494
+ accentColor: agentBySlug(slug)?.accentColor ?? null,
495
+ icon: agentBySlug(slug)?.icon ?? null,
496
+ }));
497
+ }
498
+ export async function installHooks(agent) {
499
+ const file = targetPath(agent);
500
+ const { devMode } = resolveHookScript();
501
+ if (agent === 'claude') {
502
+ hookMapInstall(file, agent, CLAUDE_HOOK_EVENTS);
503
+ return { summary: 'Installed', devMode };
504
+ }
505
+ if (agent === 'codex') {
506
+ hookMapInstall(file, agent, CODEX_HOOK_EVENTS);
507
+ try {
508
+ await enableCodexHooksFeature();
509
+ return { summary: 'Installed', devMode };
510
+ }
511
+ catch (error) {
512
+ return {
513
+ summary: `Installed, but couldn't run \`codex features enable hooks\` (${error.message}) — run it once manually`,
514
+ devMode,
515
+ };
516
+ }
517
+ }
518
+ const content = ownedFileContent(agent);
519
+ if (content === null)
520
+ throw new Error(`no owned-file integration for ${agent}`);
521
+ ownedFileInstall(file, content, marker(agent));
522
+ return { summary: 'Installed', devMode };
523
+ }
524
+ export async function uninstallHooks(agent) {
525
+ const file = targetPath(agent);
526
+ if (agent === 'claude' || agent === 'codex') {
527
+ return hookMapUninstall(file, agent);
528
+ }
529
+ return ownedFileUninstall(file, marker(agent));
530
+ }
531
+ /**
532
+ * Startup keeper: rewrite any integration that is installed but stale so
533
+ * hooks keep pointing at a real termdock after the package moves or updates.
534
+ * Returns how many integrations were refreshed.
535
+ */
536
+ export function refreshStaleHooksAtLaunch() {
537
+ let refreshed = 0;
538
+ for (const agent of HOOK_AGENTS) {
539
+ if (hooksState(agent) !== 'outdated')
540
+ continue;
541
+ try {
542
+ void installHooks(agent);
543
+ refreshed++;
544
+ console.log(`[agent-hooks] refreshed stale ${agent} hooks at ${abbreviateHome(targetPath(agent))}`);
545
+ }
546
+ catch (error) {
547
+ console.warn(`[agent-hooks] could not refresh stale ${agent} hooks:`, error.message);
548
+ }
549
+ }
550
+ return refreshed;
551
+ }