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.
- package/dist/client/assets/{abnfDiagram-VRR7QNED-DboLYKjC.js → abnfDiagram-VRR7QNED-G_YxqZwv.js} +1 -1
- package/dist/client/assets/{arc-C7cI3SGK.js → arc-BuvKiJCC.js} +1 -1
- package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-DupwL8qH.js → architectureDiagram-ZJ3FMSHR-BvkwEVZw.js} +1 -1
- package/dist/client/assets/{blockDiagram-677ZJIJ3-DNM7fUPm.js → blockDiagram-677ZJIJ3-C3bxox0Z.js} +1 -1
- package/dist/client/assets/{c4Diagram-LMCZKHZV-Jaxf-YGr.js → c4Diagram-LMCZKHZV-DLORcr8m.js} +1 -1
- package/dist/client/assets/channel-BnGIwxUi.js +1 -0
- package/dist/client/assets/{chunk-2Q5K7J3B-CjqqaH_Z.js → chunk-2Q5K7J3B-Dnqi-8g0.js} +1 -1
- package/dist/client/assets/{chunk-32BRIVSS-BYtt3b30.js → chunk-32BRIVSS-D70B4HxL.js} +1 -1
- package/dist/client/assets/{chunk-5VM5RSS4-C68y4tT7.js → chunk-5VM5RSS4-Cl7t39aR.js} +1 -1
- package/dist/client/assets/{chunk-EX3LRPZG-BpJMVcVA.js → chunk-EX3LRPZG-DKMD4DfE.js} +1 -1
- package/dist/client/assets/{chunk-JWPE2WC7-BaYNrMI4.js → chunk-JWPE2WC7-CsN_pMOw.js} +1 -1
- package/dist/client/assets/{chunk-MOJQB5TN-y5QTOrLI.js → chunk-MOJQB5TN-D8BHaQpE.js} +1 -1
- package/dist/client/assets/{chunk-RYQCIY6F-DiHf2o1T.js → chunk-RYQCIY6F-B8BtUiHF.js} +1 -1
- package/dist/client/assets/{chunk-V7JOEXUC-9j4f9aIn.js → chunk-V7JOEXUC-CdN7tlvn.js} +1 -1
- package/dist/client/assets/{chunk-VR4S4FIN-CdbelzcZ.js → chunk-VR4S4FIN-UPjL_wl4.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-YAjZkdyb.js → chunk-XXDRQBXY--eX5Na_l.js} +1 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-DMigd9mj.js +1 -0
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-DMigd9mj.js +1 -0
- package/dist/client/assets/{cose-bilkent-JH36ORCC-lTBDAWfs.js → cose-bilkent-JH36ORCC-CEAcZx3N.js} +1 -1
- package/dist/client/assets/{cynefin-VYW2F7L2-Dl7ev6m4.js → cynefin-VYW2F7L2-up8zIMDo.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-TSTJHNR4-CSxz6O5q.js → cynefinDiagram-TSTJHNR4-DZ2SQpCq.js} +1 -1
- package/dist/client/assets/{dagre-VKFMJZFB-zWgEHxrl.js → dagre-VKFMJZFB-DOoLtq9O.js} +1 -1
- package/dist/client/assets/{diagram-FQU43EPY-BQeiowzD.js → diagram-FQU43EPY-x8umATxt.js} +1 -1
- package/dist/client/assets/{diagram-G47NLZAW-DJezXuHM.js → diagram-G47NLZAW-Kle7Toxb.js} +1 -1
- package/dist/client/assets/{diagram-NH7WQ7WH-D1d1WkdQ.js → diagram-NH7WQ7WH-CezkkMqP.js} +1 -1
- package/dist/client/assets/{diagram-OA4YK3LP-C2ZpnAsd.js → diagram-OA4YK3LP-CaU0u6UQ.js} +1 -1
- package/dist/client/assets/{diagram-WEI45ONY-DH9kmAm9.js → diagram-WEI45ONY-B4m53TVj.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-CCIWWBDH-CjnajZxN.js → ebnfDiagram-CCIWWBDH-Dpwjo8_v.js} +1 -1
- package/dist/client/assets/{erDiagram-Q63AITRT-CzmBQFbx.js → erDiagram-Q63AITRT-BU4R1QVk.js} +1 -1
- package/dist/client/assets/{flowDiagram-23GEKE2U-ljeoJhTo.js → flowDiagram-23GEKE2U-BFtDsA0k.js} +1 -1
- package/dist/client/assets/{ganttDiagram-NO4QXBWP-DF3m0dot.js → ganttDiagram-NO4QXBWP-DtSEfub5.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-BwB7r571.js → gitGraphDiagram-IHSO6WYX-BGukYlT3.js} +1 -1
- package/dist/client/assets/index-CP3_puZd.js +395 -0
- package/dist/client/assets/index-CxBkmASY.css +1 -0
- package/dist/client/assets/{infoDiagram-FWYZ7A6U-Cv9S0ijK.js → infoDiagram-FWYZ7A6U-Bx1NQNsL.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-DRecz33b.js → ishikawaDiagram-FXEZZL3T-DHlxYJjl.js} +1 -1
- package/dist/client/assets/{journeyDiagram-5HDEW3XC-BLRRylnR.js → journeyDiagram-5HDEW3XC-BOZzlR6d.js} +1 -1
- package/dist/client/assets/{kanban-definition-HUTT4EX6-BC1ph5cw.js → kanban-definition-HUTT4EX6-C2nWuUXX.js} +1 -1
- package/dist/client/assets/{linear-C2zeZj0z.js → linear-DtblW0g4.js} +1 -1
- package/dist/client/assets/{mermaid.core-BqDN_zP4.js → mermaid.core-BKSuG721.js} +4 -4
- package/dist/client/assets/{mindmap-definition-LN4V7U3C-BpDX-24D.js → mindmap-definition-LN4V7U3C-Co3LLQGt.js} +1 -1
- package/dist/client/assets/{pegDiagram-2B236MQR-Sn0Dke6L.js → pegDiagram-2B236MQR-DSUG1nB1.js} +1 -1
- package/dist/client/assets/{pieDiagram-ENE6RG2P-DzMhYGWJ.js → pieDiagram-ENE6RG2P-DvEr0UPf.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-CuQoPglS.js → quadrantDiagram-ABIIQ3AL-C2diLBZ8.js} +1 -1
- package/dist/client/assets/{railroadDiagram-RFXS5EU6-C4hKErDp.js → railroadDiagram-RFXS5EU6-CqYZHCvf.js} +1 -1
- package/dist/client/assets/{requirementDiagram-TGXJPOKE-CE5J97_g.js → requirementDiagram-TGXJPOKE-CFAKdPQp.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-HTMAVEWB-BDyhQ6qz.js → sankeyDiagram-HTMAVEWB-DV95NA9H.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-CW-oi8so.js → sequenceDiagram-DBY2YBRQ-cbv0xzG-.js} +1 -1
- package/dist/client/assets/{sizeCapture-X5ZJPWSS-mMoYKQSz.js → sizeCapture-X5ZJPWSS-DpkzYB1F.js} +1 -1
- package/dist/client/assets/{stateDiagram-2N3HPSRC-BUd0G8S2.js → stateDiagram-2N3HPSRC-BnAwPP0V.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DtPm_oHq.js +1 -0
- package/dist/client/assets/{swimlanes-5IMT3BWC-B8hw4xPM.js → swimlanes-5IMT3BWC-kObRIKfj.js} +2 -2
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-yeb4EUGj.js +8 -0
- package/dist/client/assets/{timeline-definition-FHXFAJF6-DykkYG1G.js → timeline-definition-FHXFAJF6-BbnAJ8pO.js} +1 -1
- package/dist/client/assets/{vennDiagram-L72KCM5P-BBkpVj6b.js → vennDiagram-L72KCM5P-sIu_hWKx.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-EHGQE667-ClqSnTks.js → wardleyDiagram-EHGQE667-DpGdJtdb.js} +1 -1
- package/dist/client/assets/{xychartDiagram-FW5EYKEG-Ba_5tcTW.js → xychartDiagram-FW5EYKEG-C5F9faN2.js} +1 -1
- package/dist/client/icons/agents/amp.svg +12 -0
- package/dist/client/icons/agents/claude.svg +10 -0
- package/dist/client/icons/agents/codex.svg +10 -0
- package/dist/client/icons/agents/copilot.svg +12 -0
- package/dist/client/icons/agents/cursor.svg +3 -0
- package/dist/client/icons/agents/droid.svg +10 -0
- package/dist/client/icons/agents/gemini.svg +5 -0
- package/dist/client/icons/agents/goose.svg +10 -0
- package/dist/client/icons/agents/grok.svg +5 -0
- package/dist/client/icons/agents/opencode.svg +3 -0
- package/dist/client/index.html +2 -2
- package/dist/client/sw.js +1 -1
- package/dist/server/agent/installers.js +551 -0
- package/dist/server/agent/registry.js +244 -0
- package/dist/server/agent/session.js +162 -0
- package/dist/server/agentHook.js +174 -0
- package/dist/server/ptyHost.js +261 -0
- package/dist/server/ptyhost/manager.js +396 -0
- package/dist/server/ptyhost/protocol.js +80 -0
- package/dist/server/routes/terminal.js +829 -395
- package/dist/server/utils/gitStatus.js +114 -0
- package/package.json +1 -1
- package/dist/client/assets/channel-D1achAhM.js +0 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-BiqbrzEM.js +0 -1
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-BiqbrzEM.js +0 -1
- package/dist/client/assets/index-QtbZAkiJ.css +0 -1
- package/dist/client/assets/index-odB6evg3.js +0 -394
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-XLkUpRKa.js +0 -1
- 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
|
+
}
|