termdock 1.3.1 → 1.4.1
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-VDfhcxCs.js → abnfDiagram-VRR7QNED-C7YJqmLC.js} +1 -1
- package/dist/client/assets/{arc-CK3Ni_GA.js → arc-B07DueP1.js} +1 -1
- package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-QKnogVlc.js → architectureDiagram-ZJ3FMSHR-DRKN8EGo.js} +1 -1
- package/dist/client/assets/{blockDiagram-677ZJIJ3-B6KYTMUm.js → blockDiagram-677ZJIJ3-BwR-JbpT.js} +1 -1
- package/dist/client/assets/{c4Diagram-LMCZKHZV-CempTpbh.js → c4Diagram-LMCZKHZV-DyRja9hI.js} +1 -1
- package/dist/client/assets/channel-DMZ6Nll6.js +1 -0
- package/dist/client/assets/{chunk-2Q5K7J3B-BIfNQQhi.js → chunk-2Q5K7J3B-Cl0vEDWK.js} +1 -1
- package/dist/client/assets/{chunk-32BRIVSS-BjGUsxBo.js → chunk-32BRIVSS-D-cTwhqE.js} +1 -1
- package/dist/client/assets/{chunk-5VM5RSS4-YOIRo4TG.js → chunk-5VM5RSS4-D0eRXduK.js} +1 -1
- package/dist/client/assets/{chunk-EX3LRPZG-DYok0zQS.js → chunk-EX3LRPZG-J_Nw8oeX.js} +1 -1
- package/dist/client/assets/{chunk-JWPE2WC7-jJpFBvnD.js → chunk-JWPE2WC7-D73gFLxs.js} +1 -1
- package/dist/client/assets/{chunk-MOJQB5TN-Cfv8l9NT.js → chunk-MOJQB5TN-DZtboEtR.js} +1 -1
- package/dist/client/assets/{chunk-RYQCIY6F-DezUuSQG.js → chunk-RYQCIY6F-D7BwVcOs.js} +1 -1
- package/dist/client/assets/{chunk-V7JOEXUC-DubthR3B.js → chunk-V7JOEXUC-B63BJcBW.js} +1 -1
- package/dist/client/assets/{chunk-VR4S4FIN-Br17FuD9.js → chunk-VR4S4FIN-C502LPuE.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-BEp2DDT5.js → chunk-XXDRQBXY-C9puam3W.js} +1 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-C7oNngQj.js +1 -0
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-C7oNngQj.js +1 -0
- package/dist/client/assets/{cose-bilkent-JH36ORCC-De1yyf-M.js → cose-bilkent-JH36ORCC-CZ24meVw.js} +1 -1
- package/dist/client/assets/{cynefin-VYW2F7L2-DESao676.js → cynefin-VYW2F7L2-Y2fr06hq.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-TSTJHNR4-BHRUq34L.js → cynefinDiagram-TSTJHNR4-OF_nmz5v.js} +1 -1
- package/dist/client/assets/{dagre-VKFMJZFB-q7dQXBcZ.js → dagre-VKFMJZFB-svS4Qj1B.js} +1 -1
- package/dist/client/assets/{diagram-FQU43EPY-DK8KUQgs.js → diagram-FQU43EPY-Bim50nBZ.js} +1 -1
- package/dist/client/assets/{diagram-G47NLZAW-CttMqM35.js → diagram-G47NLZAW-BDGzIPU6.js} +1 -1
- package/dist/client/assets/{diagram-NH7WQ7WH-DdPuemYp.js → diagram-NH7WQ7WH-DP8vmwhI.js} +1 -1
- package/dist/client/assets/{diagram-OA4YK3LP-BbRCM1am.js → diagram-OA4YK3LP-CVwGpEzy.js} +1 -1
- package/dist/client/assets/{diagram-WEI45ONY-Bsx5Tgzq.js → diagram-WEI45ONY-nS0Aj3Pp.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-CCIWWBDH-B_t6Kh6T.js → ebnfDiagram-CCIWWBDH-BogvM--v.js} +1 -1
- package/dist/client/assets/{erDiagram-Q63AITRT-C9R1g3WF.js → erDiagram-Q63AITRT-pBjz5SjG.js} +1 -1
- package/dist/client/assets/{flowDiagram-23GEKE2U-Cb9bCkNG.js → flowDiagram-23GEKE2U-BINUCOud.js} +1 -1
- package/dist/client/assets/{ganttDiagram-NO4QXBWP-BiDqptF0.js → ganttDiagram-NO4QXBWP-DITnKqUR.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-Bb5ly04l.js → gitGraphDiagram-IHSO6WYX-C_C74_pU.js} +1 -1
- package/dist/client/assets/index-DgG9nafK.css +1 -0
- package/dist/client/assets/index-VDGjYWHR.js +408 -0
- package/dist/client/assets/{infoDiagram-FWYZ7A6U-CE_9WW2K.js → infoDiagram-FWYZ7A6U-D4WLtM02.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-D6Xq4-fM.js → ishikawaDiagram-FXEZZL3T-tgP7oAl9.js} +1 -1
- package/dist/client/assets/{journeyDiagram-5HDEW3XC-BR9hIye8.js → journeyDiagram-5HDEW3XC-sK0S0EXp.js} +1 -1
- package/dist/client/assets/{kanban-definition-HUTT4EX6-CuCbS_Ki.js → kanban-definition-HUTT4EX6-BZVK0aeo.js} +1 -1
- package/dist/client/assets/{linear-SWu8Le4S.js → linear-CHubENPf.js} +1 -1
- package/dist/client/assets/{mermaid.core-BBUMzzeH.js → mermaid.core-2dLzSZxi.js} +4 -4
- package/dist/client/assets/{mindmap-definition-LN4V7U3C-CQ0baFfF.js → mindmap-definition-LN4V7U3C-C28O3edf.js} +1 -1
- package/dist/client/assets/{pegDiagram-2B236MQR-ttatanL5.js → pegDiagram-2B236MQR-DaTkErYV.js} +1 -1
- package/dist/client/assets/{pieDiagram-ENE6RG2P-CPfxzHzu.js → pieDiagram-ENE6RG2P-CoUX2zjE.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-BXaRswdp.js → quadrantDiagram-ABIIQ3AL-DdidrEKm.js} +1 -1
- package/dist/client/assets/{railroadDiagram-RFXS5EU6-DrjoYMhE.js → railroadDiagram-RFXS5EU6-B4ua4pek.js} +1 -1
- package/dist/client/assets/{requirementDiagram-TGXJPOKE-DhVxD_8O.js → requirementDiagram-TGXJPOKE-C7GkSVWe.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-HTMAVEWB-COK6i599.js → sankeyDiagram-HTMAVEWB-D2qKgrSf.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-PVFMoTPN.js → sequenceDiagram-DBY2YBRQ-BKpN5Rg1.js} +1 -1
- package/dist/client/assets/{sizeCapture-X5ZJPWSS-DOVi8Y5x.js → sizeCapture-X5ZJPWSS-CdOyl0Mu.js} +1 -1
- package/dist/client/assets/{stateDiagram-2N3HPSRC-Bn0vvGOe.js → stateDiagram-2N3HPSRC-Pig7diHZ.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DaioXNnb.js +1 -0
- package/dist/client/assets/{swimlanes-5IMT3BWC-D5QZ4K6L.js → swimlanes-5IMT3BWC-PS6bSiiJ.js} +2 -2
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-B9vE1I0i.js +8 -0
- package/dist/client/assets/{timeline-definition-FHXFAJF6-DUhdUNzM.js → timeline-definition-FHXFAJF6-BjVebJ90.js} +1 -1
- package/dist/client/assets/{vennDiagram-L72KCM5P-p95xTZY4.js → vennDiagram-L72KCM5P-BtjZHsBe.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-EHGQE667-DjIHqD-A.js → wardleyDiagram-EHGQE667-DmmuPtgr.js} +1 -1
- package/dist/client/assets/{xychartDiagram-FW5EYKEG-DMeL47EZ.js → xychartDiagram-FW5EYKEG-CPh2RG2Y.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 +742 -0
- package/dist/server/agent/plugins.js +271 -0
- package/dist/server/agent/registry.js +306 -0
- package/dist/server/agent/session.js +162 -0
- package/dist/server/agentHook.js +174 -0
- package/dist/server/cli.js +122 -2
- 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/filesystem.js +10 -8
- package/dist/server/routes/terminal.js +798 -390
- package/dist/server/utils/gitStatus.js +114 -0
- package/package.json +1 -1
- package/dist/client/assets/channel-B_qG4XEr.js +0 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-DsBZBOre.js +0 -1
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-DsBZBOre.js +0 -1
- package/dist/client/assets/index-BeJrvXOJ.js +0 -394
- package/dist/client/assets/index-iK4yVqAA.css +0 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-D_c5gxfu.js +0 -1
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-DsiBwV8W.js +0 -8
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rich agent session status — the OSC 777 sentinel protocol + per-pane state
|
|
3
|
+
* machine. (设计移植自 tty7 core/cli_agent.rs,Apache-2.0;TypeScript 重写)
|
|
4
|
+
*
|
|
5
|
+
* Agent-side hooks (installed by `installers.ts`, or hand-wired) emit an
|
|
6
|
+
* OSC 777 notification whose title is AGENT_EVENT_SENTINEL and whose body is
|
|
7
|
+
* a small JSON event. The server sniffs those out of the PTY stream, folds
|
|
8
|
+
* them through `applyAgentEvent`, and streams state changes to clients for
|
|
9
|
+
* status dots, "needs your input" notifications, and session resume.
|
|
10
|
+
*
|
|
11
|
+
* Wire shape: `ESC ] 777 ; notify ; termdock://cli-agent ; {"v":1,…} BEL`
|
|
12
|
+
*/
|
|
13
|
+
import { agentBySlug } from './registry.js';
|
|
14
|
+
export const AGENT_EVENT_SENTINEL = 'termdock://cli-agent';
|
|
15
|
+
export const AGENT_EVENT_PROTOCOL_VERSION = 1;
|
|
16
|
+
const EVENT_KINDS = new Set([
|
|
17
|
+
'session-start', 'prompt-submit', 'permission-request', 'question-asked',
|
|
18
|
+
'tool-complete', 'notification', 'stop', 'session-end',
|
|
19
|
+
]);
|
|
20
|
+
export function defaultAgentSessionState() {
|
|
21
|
+
return {
|
|
22
|
+
status: 'idle',
|
|
23
|
+
message: null,
|
|
24
|
+
sessionId: null,
|
|
25
|
+
launchArgv: null,
|
|
26
|
+
rich: false,
|
|
27
|
+
agentCwd: null,
|
|
28
|
+
activity: 0,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Fold one rich event into the state. Pure transition function — the caller
|
|
33
|
+
* owns *when* to call it and who to tell.
|
|
34
|
+
*/
|
|
35
|
+
export function applyAgentEvent(state, ev) {
|
|
36
|
+
state.rich = true;
|
|
37
|
+
if (ev.sessionId)
|
|
38
|
+
state.sessionId = ev.sessionId;
|
|
39
|
+
if (ev.cwd)
|
|
40
|
+
state.agentCwd = ev.cwd;
|
|
41
|
+
switch (ev.kind) {
|
|
42
|
+
case 'session-start':
|
|
43
|
+
state.status = 'idle';
|
|
44
|
+
state.message = null;
|
|
45
|
+
break;
|
|
46
|
+
case 'prompt-submit':
|
|
47
|
+
state.status = 'working';
|
|
48
|
+
state.message = null;
|
|
49
|
+
break;
|
|
50
|
+
// Explicit blocks from agents that distinguish them (Codex/OpenCode
|
|
51
|
+
// plugins): always the urgent "needs you" state.
|
|
52
|
+
case 'permission-request':
|
|
53
|
+
case 'question-asked':
|
|
54
|
+
state.status = 'waiting';
|
|
55
|
+
state.message = ev.message;
|
|
56
|
+
break;
|
|
57
|
+
// Claude Code overloads its single Notification hook: it fires *mid-turn*
|
|
58
|
+
// for a permission/decision prompt (a genuine block), but ALSO *between*
|
|
59
|
+
// turns as an idle "waiting for your input" reminder — which must not
|
|
60
|
+
// masquerade as a block. Escalate only when a turn is actually in flight;
|
|
61
|
+
// keyed on turn phase, not the message text, so it survives version/locale
|
|
62
|
+
// changes.
|
|
63
|
+
case 'notification':
|
|
64
|
+
if (state.status === 'working') {
|
|
65
|
+
state.status = 'waiting';
|
|
66
|
+
state.message = ev.message;
|
|
67
|
+
}
|
|
68
|
+
break;
|
|
69
|
+
// A tool call finished. Only meaningful as the recovery edge out of a
|
|
70
|
+
// block: the user answered the permission prompt, the approved tool ran,
|
|
71
|
+
// so the turn is moving again — no agent emits an explicit "permission
|
|
72
|
+
// replied" signal, so the next tool completion is that signal. Guarded on
|
|
73
|
+
// waiting so the steady stream of completions during normal work is a
|
|
74
|
+
// no-op and can never overwrite done between turns.
|
|
75
|
+
case 'tool-complete':
|
|
76
|
+
// The count moves even when the status doesn't: a tool call is the one
|
|
77
|
+
// signal that the working tree may have just changed mid-turn.
|
|
78
|
+
state.activity = (state.activity + 1) >>> 0;
|
|
79
|
+
if (state.status === 'waiting') {
|
|
80
|
+
state.status = 'working';
|
|
81
|
+
state.message = null;
|
|
82
|
+
}
|
|
83
|
+
break;
|
|
84
|
+
case 'stop':
|
|
85
|
+
state.status = 'done';
|
|
86
|
+
state.message = ev.message;
|
|
87
|
+
break;
|
|
88
|
+
// The agent session ended but its id stays: agents can resume an *ended*
|
|
89
|
+
// session, which is exactly what restore does. Its cwd claim does NOT
|
|
90
|
+
// stay: with no agent running, the pane's real directory is the truth.
|
|
91
|
+
case 'session-end':
|
|
92
|
+
state.status = 'idle';
|
|
93
|
+
state.message = null;
|
|
94
|
+
state.agentCwd = null;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function nonEmpty(s) {
|
|
99
|
+
return typeof s === 'string' && s.trim().length > 0 ? s : null;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Parse a complete OSC payload (identifier included, e.g.
|
|
103
|
+
* `777;notify;termdock://cli-agent;{"v":1,…}`) into an AgentEvent. Returns
|
|
104
|
+
* null for anything that isn't a well-formed sentinel event — including
|
|
105
|
+
* unknown `event` values, so the protocol can grow without old servers
|
|
106
|
+
* mis-classifying new events.
|
|
107
|
+
*/
|
|
108
|
+
export function parseAgentEvent(payload) {
|
|
109
|
+
if (!payload.startsWith('777;notify;'))
|
|
110
|
+
return null;
|
|
111
|
+
const rest = payload.slice('777;notify;'.length);
|
|
112
|
+
if (!rest.startsWith(AGENT_EVENT_SENTINEL))
|
|
113
|
+
return null;
|
|
114
|
+
const json = rest.slice(AGENT_EVENT_SENTINEL.length);
|
|
115
|
+
if (!json.startsWith(';'))
|
|
116
|
+
return null;
|
|
117
|
+
let w;
|
|
118
|
+
try {
|
|
119
|
+
w = JSON.parse(json.slice(1));
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
if (!w || typeof w !== 'object')
|
|
125
|
+
return null;
|
|
126
|
+
const eventName = nonEmpty(w.event);
|
|
127
|
+
if (!eventName || !EVENT_KINDS.has(eventName))
|
|
128
|
+
return null;
|
|
129
|
+
return {
|
|
130
|
+
agent: agentBySlug(nonEmpty(w.agent)),
|
|
131
|
+
kind: eventName,
|
|
132
|
+
sessionId: nonEmpty(w.session_id),
|
|
133
|
+
message: nonEmpty(w.message),
|
|
134
|
+
cwd: nonEmpty(w.cwd),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Build the sentinel OSC sequence for one hook invocation — the emitter side,
|
|
139
|
+
* kept here so tests can round-trip it through `parseAgentEvent`.
|
|
140
|
+
* `payloadJson` is the hook's raw stdin JSON from the agent; fields are lifted
|
|
141
|
+
* into the sentinel body (snake_case, with camelCase aliases for Grok).
|
|
142
|
+
*/
|
|
143
|
+
export function buildHookSequence(agent, event, stdinJson) {
|
|
144
|
+
let payload = {};
|
|
145
|
+
try {
|
|
146
|
+
const parsed = JSON.parse(stdinJson);
|
|
147
|
+
if (parsed && typeof parsed === 'object')
|
|
148
|
+
payload = parsed;
|
|
149
|
+
}
|
|
150
|
+
catch { /* garbage stdin still yields a well-formed bare event */ }
|
|
151
|
+
const body = {
|
|
152
|
+
v: AGENT_EVENT_PROTOCOL_VERSION,
|
|
153
|
+
agent,
|
|
154
|
+
event,
|
|
155
|
+
};
|
|
156
|
+
for (const [key, alias] of [['session_id', 'sessionId'], ['message', 'message'], ['cwd', 'cwd']]) {
|
|
157
|
+
const v = nonEmpty(payload[key]) ?? nonEmpty(payload[alias]);
|
|
158
|
+
if (v)
|
|
159
|
+
body[key] = v;
|
|
160
|
+
}
|
|
161
|
+
return `\x1b]777;notify;${AGENT_EVENT_SENTINEL};${JSON.stringify(body)}\x07`;
|
|
162
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* termdock agent-hook emitter — the agent-side half of the rich status channel.
|
|
4
|
+
* (设计移植自 tty7 core/agent_hooks.rs,Apache-2.0;TypeScript/Node 重写)
|
|
5
|
+
*
|
|
6
|
+
* Invoked by agent hooks as: `node agentHook.js <agent> <event>`.
|
|
7
|
+
* Reads the hook's JSON payload from stdin, builds one sentinel OSC 777
|
|
8
|
+
* sequence, and writes it to the controlling terminal, where the termdock
|
|
9
|
+
* server sniffs it out of the PTY stream and folds it into the pane's
|
|
10
|
+
* session state.
|
|
11
|
+
*
|
|
12
|
+
* Emission is gated on the TERMDOCK environment variable (injected into every
|
|
13
|
+
* shell termdock spawns / every managed tmux session), so hooks installed
|
|
14
|
+
* globally stay silent when an agent runs in another terminal.
|
|
15
|
+
*
|
|
16
|
+
* Always exits 0 quietly — a hook that fails must never break the agent's
|
|
17
|
+
* own flow (agents surface nonzero exits).
|
|
18
|
+
*
|
|
19
|
+
* This entry must stay dependency-light (fast cold start): only stdlib +
|
|
20
|
+
* the pure protocol module.
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import { execFileSync } from 'node:child_process';
|
|
24
|
+
import { buildHookSequence } from './agent/session.js';
|
|
25
|
+
/** The env var termdock sets in every spawned shell; the emitter refuses to
|
|
26
|
+
* write escape sequences into terminals that aren't termdock's. */
|
|
27
|
+
const TERMDOCK_ENV_MARKER = 'TERMDOCK';
|
|
28
|
+
/** Env var Grok Build's hook runner injects into every hook process it spawns.
|
|
29
|
+
* Its presence identifies *who ran us* — grok also scans ~/.claude/settings.json
|
|
30
|
+
* for hooks (its Claude-compat layer), so a termdock Claude Code integration
|
|
31
|
+
* also fires inside grok panes and must be relabeled to the agent that
|
|
32
|
+
* actually ran it. */
|
|
33
|
+
const GROK_HOOK_ENV = 'GROK_HOOK_EVENT';
|
|
34
|
+
/** Cap on how much hook stdin we'll read: real payloads are a few hundred
|
|
35
|
+
* bytes of JSON; anything huge is not for us. */
|
|
36
|
+
const MAX_STDIN = 64 * 1024;
|
|
37
|
+
/**
|
|
38
|
+
* The sentinel event one hook invocation maps onto, or null to stay silent.
|
|
39
|
+
* Most hooks pass their event through; the exceptions are the single catch-all
|
|
40
|
+
* `notification` hook Copilot and Grok expose, which fires for *every*
|
|
41
|
+
* notification type. Only the types that always mean a real block escalate to
|
|
42
|
+
* `permission-request`; everything else is dropped rather than parroted.
|
|
43
|
+
*
|
|
44
|
+
* The two agents draw that line differently. Copilot's `permission_prompt`
|
|
45
|
+
* only fires when it is actually asking, so it counts; grok dispatches the
|
|
46
|
+
* same type *before* its permission system decides — on essentially every tool
|
|
47
|
+
* call, auto-approved ones included — so only `elicitation_dialog` (grok
|
|
48
|
+
* asking the user a question) survives there.
|
|
49
|
+
*/
|
|
50
|
+
function effectiveEvent(agent, event, stdinJson) {
|
|
51
|
+
if ((agent === 'copilot' || agent === 'grok') && event === 'notification') {
|
|
52
|
+
const blocks = stdinJson.includes('elicitation_dialog')
|
|
53
|
+
|| (agent === 'copilot' && stdinJson.includes('permission_prompt'));
|
|
54
|
+
return blocks ? 'permission-request' : null;
|
|
55
|
+
}
|
|
56
|
+
return event;
|
|
57
|
+
}
|
|
58
|
+
/** Write raw bytes to a tty device, best-effort. */
|
|
59
|
+
function writeDev(path, bytes) {
|
|
60
|
+
try {
|
|
61
|
+
const fd = fs.openSync(path, 'w');
|
|
62
|
+
try {
|
|
63
|
+
fs.writeSync(fd, bytes);
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
fs.closeSync(fd);
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The controlling-tty device of the nearest ancestor that has one — the agent
|
|
76
|
+
* process, when it ran us detached. Claude Code runs hooks *detached* from
|
|
77
|
+
* the controlling terminal (they have no /dev/tty), but the agent process
|
|
78
|
+
* itself still owns the pane's PTY slave. Walk up the parent chain via `ps`
|
|
79
|
+
* (the hook runs at most a few times per turn, so the spawn is negligible).
|
|
80
|
+
*/
|
|
81
|
+
function ancestorTtyDevice() {
|
|
82
|
+
let pid = process.ppid;
|
|
83
|
+
for (let i = 0; i < 8 && pid > 1; i++) {
|
|
84
|
+
let tty = '';
|
|
85
|
+
let ppid = 1;
|
|
86
|
+
try {
|
|
87
|
+
const out = execFileSync('ps', ['-o', 'tty=', '-o', 'ppid=', '-p', String(pid)], {
|
|
88
|
+
encoding: 'utf8',
|
|
89
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
90
|
+
timeout: 2000,
|
|
91
|
+
});
|
|
92
|
+
const fields = out.trim().split(/\s+/);
|
|
93
|
+
tty = fields[0] ?? '';
|
|
94
|
+
ppid = parseInt(fields[1] ?? '1', 10) || 1;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
if (tty && tty !== '??' && tty !== '?') {
|
|
100
|
+
return `/dev/${tty}`;
|
|
101
|
+
}
|
|
102
|
+
pid = ppid;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Write the sequence to the pane's PTY so the server sniffs it as pane output.
|
|
108
|
+
* Two routes: /dev/tty (hook attached to its tty), or an ancestor's tty
|
|
109
|
+
* device (agent ran us detached — write its PTY slave directly; writing the
|
|
110
|
+
* slave sends output to the master, exactly like /dev/tty would).
|
|
111
|
+
*
|
|
112
|
+
* Inside tmux the pane's tty is tmux's own PTY slave: a bare OSC would be
|
|
113
|
+
* consumed by tmux and never reach the attached client. When $TMUX is set we
|
|
114
|
+
* wrap the sequence in a DCS passthrough, which tmux (with allow-passthrough
|
|
115
|
+
* enabled, as termdock sets on managed sessions) forwards to its client —
|
|
116
|
+
* the termdock server — as the plain inner OSC.
|
|
117
|
+
*/
|
|
118
|
+
function writeToControllingTty(sequence, inTmuxPane) {
|
|
119
|
+
// Only genuine tmux panes (TMUX_PANE is set by tmux itself) get the DCS
|
|
120
|
+
// passthrough wrap; a bare inherited TMUX just means an ancestor ran tmux.
|
|
121
|
+
const wire = inTmuxPane
|
|
122
|
+
? `\x1bPtmux;${sequence.replace(/\x1b/g, '\x1b\x1b')}\x1b\\`
|
|
123
|
+
: sequence;
|
|
124
|
+
if (writeDev('/dev/tty', wire))
|
|
125
|
+
return true;
|
|
126
|
+
const dev = ancestorTtyDevice();
|
|
127
|
+
if (dev)
|
|
128
|
+
return writeDev(dev, wire);
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
function main() {
|
|
132
|
+
try {
|
|
133
|
+
// Gate: emit when (a) inside a termdock-spawned shell (TERMDOCK env), or
|
|
134
|
+
// (b) inside any tmux pane (TMUX+TMUX_PANE) — there the passthrough wrap
|
|
135
|
+
// self-gates: sessions without allow-passthrough (anything termdock
|
|
136
|
+
// doesn't manage) swallow the sequence harmlessly. Anything else: stay
|
|
137
|
+
// silent, so globally-installed hooks don't leak into other terminals.
|
|
138
|
+
const inTmuxPane = !!(process.env.TMUX && process.env.TMUX_PANE);
|
|
139
|
+
if (!process.env[TERMDOCK_ENV_MARKER] && !inTmuxPane)
|
|
140
|
+
return;
|
|
141
|
+
// Command line: `agentHook.js agent-hook <agent> <event>` — the literal
|
|
142
|
+
// `agent-hook <slug>` token doubles as the installer's ownership marker.
|
|
143
|
+
const args = process.argv.slice(2);
|
|
144
|
+
if (args[0] === 'agent-hook')
|
|
145
|
+
args.shift();
|
|
146
|
+
let agent = args[0] ?? '';
|
|
147
|
+
const eventArg = args[1] ?? '';
|
|
148
|
+
if (!agent || !eventArg)
|
|
149
|
+
return;
|
|
150
|
+
if (process.env[GROK_HOOK_ENV])
|
|
151
|
+
agent = 'grok';
|
|
152
|
+
// Hook payload: the agent writes JSON and closes stdin. Absent/malformed
|
|
153
|
+
// input still emits the bare event — the state machine works without ids
|
|
154
|
+
// or messages. A tty stdin means the spawner inherited the pane's terminal
|
|
155
|
+
// instead of piping a payload; reading it would block forever on an EOF
|
|
156
|
+
// that never comes and swallow the user's keystrokes, so skip it.
|
|
157
|
+
let input = '';
|
|
158
|
+
if (!process.stdin.isTTY) {
|
|
159
|
+
try {
|
|
160
|
+
input = fs.readFileSync(0, 'utf8').slice(0, MAX_STDIN);
|
|
161
|
+
}
|
|
162
|
+
catch { /* no payload */ }
|
|
163
|
+
}
|
|
164
|
+
const event = effectiveEvent(agent, eventArg, input);
|
|
165
|
+
if (!event)
|
|
166
|
+
return;
|
|
167
|
+
writeToControllingTty(buildHookSequence(agent, event, input), inTmuxPane);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// Never break the agent's flow.
|
|
171
|
+
}
|
|
172
|
+
process.exit(0);
|
|
173
|
+
}
|
|
174
|
+
main();
|
package/dist/server/cli.js
CHANGED
|
@@ -145,8 +145,11 @@ Options:
|
|
|
145
145
|
--change-audit-show <id> [cwd]
|
|
146
146
|
Print one hunk's diff/context by Termdock hunk ID.
|
|
147
147
|
--change-audit-explain <id> [cwd]
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
Read a natural-language explanation from stdin and inject
|
|
149
|
+
it for the specified Termdock hunk ID.
|
|
150
|
+
--agent-plugin-init <slug>
|
|
151
|
+
Scaffold a new agent plugin with a manifest template and
|
|
152
|
+
guidance. Creates ~/.termdock/agent-plugins/<slug>/.
|
|
150
153
|
-h, --help Show this help message
|
|
151
154
|
|
|
152
155
|
Short commands:
|
|
@@ -178,6 +181,7 @@ Short commands:
|
|
|
178
181
|
pc Same as --clear-password
|
|
179
182
|
pwc Same as --clear-password
|
|
180
183
|
https Same as --setup-local-https
|
|
184
|
+
ap <slug> Same as --agent-plugin-init <slug>
|
|
181
185
|
|
|
182
186
|
Password examples:
|
|
183
187
|
${c.dim('# Set password interactively (recommended)')}
|
|
@@ -465,6 +469,8 @@ function parseArgs(argv) {
|
|
|
465
469
|
let changeAuditList;
|
|
466
470
|
let changeAuditShow;
|
|
467
471
|
let injectChangeAuditHunk;
|
|
472
|
+
let agentPluginInit = false;
|
|
473
|
+
let agentPluginSlug;
|
|
468
474
|
// Short command aliases for the common path. Keep these positional-only so
|
|
469
475
|
// long-form flags remain the single source of truth for option semantics.
|
|
470
476
|
if (argv[0] && !argv[0].startsWith('-')) {
|
|
@@ -560,6 +566,11 @@ function parseArgs(argv) {
|
|
|
560
566
|
injectChangeAuditHunk = { id: next, cwd };
|
|
561
567
|
argv = argv.slice(cwd ? 3 : 2);
|
|
562
568
|
}
|
|
569
|
+
else if (command === 'ap' && next && !next.startsWith('-')) {
|
|
570
|
+
agentPluginInit = true;
|
|
571
|
+
agentPluginSlug = next;
|
|
572
|
+
argv = argv.slice(2);
|
|
573
|
+
}
|
|
563
574
|
}
|
|
564
575
|
for (let index = 0; index < argv.length; index += 1) {
|
|
565
576
|
const arg = argv[index];
|
|
@@ -732,6 +743,17 @@ function parseArgs(argv) {
|
|
|
732
743
|
index += cwd ? 2 : 1;
|
|
733
744
|
continue;
|
|
734
745
|
}
|
|
746
|
+
if (arg === '--agent-plugin-init') {
|
|
747
|
+
agentPluginInit = true;
|
|
748
|
+
const slug = argv[index + 1];
|
|
749
|
+
if (!slug || slug.startsWith('-')) {
|
|
750
|
+
console.error(`${ICON.err} ${c.red('--agent-plugin-init requires a slug')}`);
|
|
751
|
+
process.exit(1);
|
|
752
|
+
}
|
|
753
|
+
agentPluginSlug = slug;
|
|
754
|
+
index += 1;
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
735
757
|
if (!arg.startsWith('-')) {
|
|
736
758
|
if (attachTmux && !attachTmuxName) {
|
|
737
759
|
attachTmuxName = arg;
|
|
@@ -773,6 +795,8 @@ function parseArgs(argv) {
|
|
|
773
795
|
changeAuditList,
|
|
774
796
|
changeAuditShow,
|
|
775
797
|
injectChangeAuditHunk,
|
|
798
|
+
agentPluginInit,
|
|
799
|
+
agentPluginSlug,
|
|
776
800
|
};
|
|
777
801
|
}
|
|
778
802
|
// Reads a single line from stdin without echoing keystrokes. Used for password
|
|
@@ -2311,6 +2335,98 @@ async function refreshDefaultHttpsCertificateSafely() {
|
|
|
2311
2335
|
return false;
|
|
2312
2336
|
}
|
|
2313
2337
|
}
|
|
2338
|
+
// ---------------------------------------------------------------------------
|
|
2339
|
+
// Agent plugin scaffolding
|
|
2340
|
+
// ---------------------------------------------------------------------------
|
|
2341
|
+
const VALID_PLUGIN_SLUG_RE = /^[a-z][a-z0-9-]*$/;
|
|
2342
|
+
async function runAgentPluginInit(slug) {
|
|
2343
|
+
if (!VALID_PLUGIN_SLUG_RE.test(slug) || slug.length > 40) {
|
|
2344
|
+
console.error(`${ICON.err} ${c.red(`Invalid slug: "${slug}"`)}`);
|
|
2345
|
+
console.error(' Slug must be lowercase alphanumeric with hyphens, 1–40 chars (e.g. "my-agent").');
|
|
2346
|
+
process.exit(1);
|
|
2347
|
+
}
|
|
2348
|
+
const pluginsDir = path.join(os.homedir(), '.termdock', 'agent-plugins');
|
|
2349
|
+
const pluginDir = path.join(pluginsDir, slug);
|
|
2350
|
+
if (fs.existsSync(pluginDir)) {
|
|
2351
|
+
console.error(`${ICON.err} ${c.red(`Plugin "${slug}" already exists at ${pluginDir}`)}`);
|
|
2352
|
+
console.error(' Remove it first or use a different slug.');
|
|
2353
|
+
process.exit(1);
|
|
2354
|
+
}
|
|
2355
|
+
const manifest = {
|
|
2356
|
+
version: 1,
|
|
2357
|
+
slug,
|
|
2358
|
+
displayName: slug, // user should rename
|
|
2359
|
+
aliases: [slug],
|
|
2360
|
+
accentColor: '#4385BE',
|
|
2361
|
+
hooks: {
|
|
2362
|
+
target: `~/.${slug}/settings.json`,
|
|
2363
|
+
events: [
|
|
2364
|
+
{ hook: 'SessionStart', event: 'session-start' },
|
|
2365
|
+
{ hook: 'UserPromptSubmit', event: 'prompt-submit' },
|
|
2366
|
+
{ hook: 'Notification', event: 'notification' },
|
|
2367
|
+
{ hook: 'PostToolUse', event: 'tool-complete' },
|
|
2368
|
+
{ hook: 'Stop', event: 'stop' },
|
|
2369
|
+
{ hook: 'SessionEnd', event: 'session-end' },
|
|
2370
|
+
],
|
|
2371
|
+
},
|
|
2372
|
+
resume: {
|
|
2373
|
+
command: `${slug} --resume {sessionId}`,
|
|
2374
|
+
staleFlags: ['--resume', '-r', '--session'],
|
|
2375
|
+
},
|
|
2376
|
+
};
|
|
2377
|
+
const iconSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
2378
|
+
<circle cx="12" cy="12" r="10"/>
|
|
2379
|
+
<path d="M12 6v6l4 2"/>
|
|
2380
|
+
</svg>
|
|
2381
|
+
`;
|
|
2382
|
+
fs.mkdirSync(pluginDir, { recursive: true });
|
|
2383
|
+
fs.writeFileSync(path.join(pluginDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
|
|
2384
|
+
fs.writeFileSync(path.join(pluginDir, 'icon.svg'), iconSvg, 'utf8');
|
|
2385
|
+
console.log(`${ICON.ok} Plugin scaffolded at ${c.cyan(pluginDir)}`);
|
|
2386
|
+
console.log('');
|
|
2387
|
+
console.log(` ${c.bold('manifest.json')} — agent identity, aliases, hook events, resume command`);
|
|
2388
|
+
console.log(` ${c.bold('icon.svg')} — brand icon (SVG, used as CSS mask)`);
|
|
2389
|
+
console.log('');
|
|
2390
|
+
console.log(c.bold('Next steps:'));
|
|
2391
|
+
console.log('');
|
|
2392
|
+
console.log(` 1. Edit ${c.cyan(`"displayName"`)} — the human-readable label shown in the UI.`);
|
|
2393
|
+
console.log(` 2. Check ${c.cyan(`"aliases"`)} — add all command names your agent uses.`);
|
|
2394
|
+
console.log(` 3. Set ${c.cyan(`"accentColor"`)} to your agent's brand color (hex, e.g. "#FF6600").`);
|
|
2395
|
+
console.log(` 4. Verify ${c.cyan(`"hooks.target"`)} — the path to your agent's own hooks config file.`);
|
|
2396
|
+
console.log(` Use ${c.dim('~')}-abbreviated absolute paths (e.g. ${c.dim('~/.claude/settings.json')}).`);
|
|
2397
|
+
console.log(` 5. Map ${c.cyan(`"hooks.events"`)} — left side is your agent's event name,`);
|
|
2398
|
+
console.log(` right side is one of our sentinel events:`);
|
|
2399
|
+
console.log('');
|
|
2400
|
+
console.log(' ' + c.bold('Sentinel events (what they mean):'));
|
|
2401
|
+
console.log(` ${c.green('session-start')} — agent session begins (resets state)`);
|
|
2402
|
+
console.log(` ${c.green('prompt-submit')} — user sent a prompt (agent starts working)`);
|
|
2403
|
+
console.log(` ${c.green('permission-request')} — agent is blocked, needs your approval`);
|
|
2404
|
+
console.log(` ${c.green('question-asked')} — agent is blocked, needs your answer`);
|
|
2405
|
+
console.log(` ${c.green('notification')} — agent nudged you (only escalates mid-turn)`);
|
|
2406
|
+
console.log(` ${c.green('tool-complete')} — a tool finished (recovery from waiting)`);
|
|
2407
|
+
console.log(` ${c.green('stop')} — turn finished, agent idle`);
|
|
2408
|
+
console.log(` ${c.green('session-end')} — agent session ended (keeps resume id)`);
|
|
2409
|
+
console.log('');
|
|
2410
|
+
console.log(' Optional per-event fields:');
|
|
2411
|
+
console.log(` ${c.dim('"matcher"')} — narrow the event by type (e.g. ${c.dim('"elicitation_dialog"')})`);
|
|
2412
|
+
console.log(` ${c.dim('"timeout"')} — hook timeout in seconds (default: no limit)`);
|
|
2413
|
+
console.log('');
|
|
2414
|
+
console.log(` 6. Replace ${c.cyan('icon.svg')} with your agent's brand icon (SVG, 24×24 viewBox).`);
|
|
2415
|
+
console.log(` The icon is rendered white-on-brand-color as a CSS mask.`);
|
|
2416
|
+
console.log('');
|
|
2417
|
+
console.log(` 7. Set the ${c.cyan('"resume.command"')} to your agent's session resume command.`);
|
|
2418
|
+
console.log(` Must contain ${c.dim('{sessionId}')} — replaced with the real id at runtime.`);
|
|
2419
|
+
console.log('');
|
|
2420
|
+
console.log(' After editing, restart termdock to load the plugin:');
|
|
2421
|
+
console.log(` ${c.cyan('td --stop && td')}`);
|
|
2422
|
+
console.log('');
|
|
2423
|
+
console.log(' Then go to Settings → Agent Hooks to install hooks for your agent.');
|
|
2424
|
+
console.log('');
|
|
2425
|
+
console.log(' For manual plugins from JSON, use the "Add Plugin" panel in Settings.');
|
|
2426
|
+
console.log('');
|
|
2427
|
+
console.log(c.dim(' Built-in agents with hook support live in src/server/agent/installers.ts'));
|
|
2428
|
+
console.log(c.dim(' as a reference for more complex integrations (owned JS/TS files, etc.).'));
|
|
2429
|
+
}
|
|
2314
2430
|
async function main() {
|
|
2315
2431
|
if (options.setupLocalHttps) {
|
|
2316
2432
|
try {
|
|
@@ -2362,6 +2478,10 @@ async function main() {
|
|
|
2362
2478
|
await runInjectChangeAuditHunk(options.injectChangeAuditHunk);
|
|
2363
2479
|
process.exit(0);
|
|
2364
2480
|
}
|
|
2481
|
+
if (options.agentPluginInit) {
|
|
2482
|
+
await runAgentPluginInit(options.agentPluginSlug);
|
|
2483
|
+
process.exit(0);
|
|
2484
|
+
}
|
|
2365
2485
|
if (options.setPassword) {
|
|
2366
2486
|
await runSetPassword();
|
|
2367
2487
|
process.exit(0);
|