termdock 1.3.1 → 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-VDfhcxCs.js → abnfDiagram-VRR7QNED-G_YxqZwv.js} +1 -1
  2. package/dist/client/assets/{arc-CK3Ni_GA.js → arc-BuvKiJCC.js} +1 -1
  3. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-QKnogVlc.js → architectureDiagram-ZJ3FMSHR-BvkwEVZw.js} +1 -1
  4. package/dist/client/assets/{blockDiagram-677ZJIJ3-B6KYTMUm.js → blockDiagram-677ZJIJ3-C3bxox0Z.js} +1 -1
  5. package/dist/client/assets/{c4Diagram-LMCZKHZV-CempTpbh.js → c4Diagram-LMCZKHZV-DLORcr8m.js} +1 -1
  6. package/dist/client/assets/channel-BnGIwxUi.js +1 -0
  7. package/dist/client/assets/{chunk-2Q5K7J3B-BIfNQQhi.js → chunk-2Q5K7J3B-Dnqi-8g0.js} +1 -1
  8. package/dist/client/assets/{chunk-32BRIVSS-BjGUsxBo.js → chunk-32BRIVSS-D70B4HxL.js} +1 -1
  9. package/dist/client/assets/{chunk-5VM5RSS4-YOIRo4TG.js → chunk-5VM5RSS4-Cl7t39aR.js} +1 -1
  10. package/dist/client/assets/{chunk-EX3LRPZG-DYok0zQS.js → chunk-EX3LRPZG-DKMD4DfE.js} +1 -1
  11. package/dist/client/assets/{chunk-JWPE2WC7-jJpFBvnD.js → chunk-JWPE2WC7-CsN_pMOw.js} +1 -1
  12. package/dist/client/assets/{chunk-MOJQB5TN-Cfv8l9NT.js → chunk-MOJQB5TN-D8BHaQpE.js} +1 -1
  13. package/dist/client/assets/{chunk-RYQCIY6F-DezUuSQG.js → chunk-RYQCIY6F-B8BtUiHF.js} +1 -1
  14. package/dist/client/assets/{chunk-V7JOEXUC-DubthR3B.js → chunk-V7JOEXUC-CdN7tlvn.js} +1 -1
  15. package/dist/client/assets/{chunk-VR4S4FIN-Br17FuD9.js → chunk-VR4S4FIN-UPjL_wl4.js} +1 -1
  16. package/dist/client/assets/{chunk-XXDRQBXY-BEp2DDT5.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-De1yyf-M.js → cose-bilkent-JH36ORCC-CEAcZx3N.js} +1 -1
  20. package/dist/client/assets/{cynefin-VYW2F7L2-DESao676.js → cynefin-VYW2F7L2-up8zIMDo.js} +1 -1
  21. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-BHRUq34L.js → cynefinDiagram-TSTJHNR4-DZ2SQpCq.js} +1 -1
  22. package/dist/client/assets/{dagre-VKFMJZFB-q7dQXBcZ.js → dagre-VKFMJZFB-DOoLtq9O.js} +1 -1
  23. package/dist/client/assets/{diagram-FQU43EPY-DK8KUQgs.js → diagram-FQU43EPY-x8umATxt.js} +1 -1
  24. package/dist/client/assets/{diagram-G47NLZAW-CttMqM35.js → diagram-G47NLZAW-Kle7Toxb.js} +1 -1
  25. package/dist/client/assets/{diagram-NH7WQ7WH-DdPuemYp.js → diagram-NH7WQ7WH-CezkkMqP.js} +1 -1
  26. package/dist/client/assets/{diagram-OA4YK3LP-BbRCM1am.js → diagram-OA4YK3LP-CaU0u6UQ.js} +1 -1
  27. package/dist/client/assets/{diagram-WEI45ONY-Bsx5Tgzq.js → diagram-WEI45ONY-B4m53TVj.js} +1 -1
  28. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-B_t6Kh6T.js → ebnfDiagram-CCIWWBDH-Dpwjo8_v.js} +1 -1
  29. package/dist/client/assets/{erDiagram-Q63AITRT-C9R1g3WF.js → erDiagram-Q63AITRT-BU4R1QVk.js} +1 -1
  30. package/dist/client/assets/{flowDiagram-23GEKE2U-Cb9bCkNG.js → flowDiagram-23GEKE2U-BFtDsA0k.js} +1 -1
  31. package/dist/client/assets/{ganttDiagram-NO4QXBWP-BiDqptF0.js → ganttDiagram-NO4QXBWP-DtSEfub5.js} +1 -1
  32. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-Bb5ly04l.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-CE_9WW2K.js → infoDiagram-FWYZ7A6U-Bx1NQNsL.js} +1 -1
  36. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-D6Xq4-fM.js → ishikawaDiagram-FXEZZL3T-DHlxYJjl.js} +1 -1
  37. package/dist/client/assets/{journeyDiagram-5HDEW3XC-BR9hIye8.js → journeyDiagram-5HDEW3XC-BOZzlR6d.js} +1 -1
  38. package/dist/client/assets/{kanban-definition-HUTT4EX6-CuCbS_Ki.js → kanban-definition-HUTT4EX6-C2nWuUXX.js} +1 -1
  39. package/dist/client/assets/{linear-SWu8Le4S.js → linear-DtblW0g4.js} +1 -1
  40. package/dist/client/assets/{mermaid.core-BBUMzzeH.js → mermaid.core-BKSuG721.js} +4 -4
  41. package/dist/client/assets/{mindmap-definition-LN4V7U3C-CQ0baFfF.js → mindmap-definition-LN4V7U3C-Co3LLQGt.js} +1 -1
  42. package/dist/client/assets/{pegDiagram-2B236MQR-ttatanL5.js → pegDiagram-2B236MQR-DSUG1nB1.js} +1 -1
  43. package/dist/client/assets/{pieDiagram-ENE6RG2P-CPfxzHzu.js → pieDiagram-ENE6RG2P-DvEr0UPf.js} +1 -1
  44. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-BXaRswdp.js → quadrantDiagram-ABIIQ3AL-C2diLBZ8.js} +1 -1
  45. package/dist/client/assets/{railroadDiagram-RFXS5EU6-DrjoYMhE.js → railroadDiagram-RFXS5EU6-CqYZHCvf.js} +1 -1
  46. package/dist/client/assets/{requirementDiagram-TGXJPOKE-DhVxD_8O.js → requirementDiagram-TGXJPOKE-CFAKdPQp.js} +1 -1
  47. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-COK6i599.js → sankeyDiagram-HTMAVEWB-DV95NA9H.js} +1 -1
  48. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-PVFMoTPN.js → sequenceDiagram-DBY2YBRQ-cbv0xzG-.js} +1 -1
  49. package/dist/client/assets/{sizeCapture-X5ZJPWSS-DOVi8Y5x.js → sizeCapture-X5ZJPWSS-DpkzYB1F.js} +1 -1
  50. package/dist/client/assets/{stateDiagram-2N3HPSRC-Bn0vvGOe.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-D5QZ4K6L.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-DUhdUNzM.js → timeline-definition-FHXFAJF6-BbnAJ8pO.js} +1 -1
  55. package/dist/client/assets/{vennDiagram-L72KCM5P-p95xTZY4.js → vennDiagram-L72KCM5P-sIu_hWKx.js} +1 -1
  56. package/dist/client/assets/{wardleyDiagram-EHGQE667-DjIHqD-A.js → wardleyDiagram-EHGQE667-DpGdJtdb.js} +1 -1
  57. package/dist/client/assets/{xychartDiagram-FW5EYKEG-DMeL47EZ.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 +724 -393
  78. package/dist/server/utils/gitStatus.js +114 -0
  79. package/package.json +1 -1
  80. package/dist/client/assets/channel-B_qG4XEr.js +0 -1
  81. package/dist/client/assets/classDiagram-OUVF2IWQ-DsBZBOre.js +0 -1
  82. package/dist/client/assets/classDiagram-v2-EOCWNBFH-DsBZBOre.js +0 -1
  83. package/dist/client/assets/index-BeJrvXOJ.js +0 -394
  84. package/dist/client/assets/index-iK4yVqAA.css +0 -1
  85. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-D_c5gxfu.js +0 -1
  86. package/dist/client/assets/swimlanesDiagram-G3AALYLV-DsiBwV8W.js +0 -8
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Third-party CLI coding-agent registry + detection.
3
+ * (设计移植自 tty7 的 core/cli_agent.rs,Apache-2.0;此处为 TypeScript 重写)
4
+ *
5
+ * termdock recognizes when a pane is running a known coding agent (Claude
6
+ * Code, Codex, Gemini CLI, …) so the tab can brand it and notifications can
7
+ * say *which* agent finished or needs you. Detection is a pure function over
8
+ * the foreground process's argv (which the server already polls for program
9
+ * labels), so it works identically in shell mode and tmux mode.
10
+ *
11
+ * Strategy:
12
+ * 1. Strip leading `VAR=value` environment assignments.
13
+ * 2. If the launcher's own basename matches a known agent, that's it.
14
+ * 3. Otherwise only if the launcher is a script *interpreter* (node, bun,
15
+ * python, npx, …), scan remaining path-like arguments for a segment that
16
+ * names an agent — the npm/pip-wrapped case (`node …/claude-code/cli.js`).
17
+ * The interpreter gate keeps `cat codex.md` from false-matching.
18
+ * 4. User-defined rules (`agentCommands` in program-detection config): a map
19
+ * from command basename to agent slug, for personal wrappers (`cc`).
20
+ */
21
+ // Registry order matters only for iteration stability; lookup is by alias.
22
+ // flexoki-allow-file — vendor brand accents are deliberately theme-independent.
23
+ const AGENTS = [
24
+ { slug: 'claude', displayName: 'Claude Code', aliases: ['claude', 'claude-code'], accentColor: '#D97757', icon: 'claude' },
25
+ { slug: 'codex', displayName: 'Codex', aliases: ['codex', 'codex-cli'], accentColor: '#000000', icon: 'codex' },
26
+ { slug: 'gemini', displayName: 'Gemini', aliases: ['gemini', 'gemini-cli'], accentColor: '#4285F4', icon: 'gemini' },
27
+ { slug: 'aider', displayName: 'Aider', aliases: ['aider', 'aider-chat'], accentColor: '#14B8A6', icon: null },
28
+ { slug: 'amp', displayName: 'Amp', aliases: ['amp'], accentColor: '#F34E3F', icon: 'amp' },
29
+ { slug: 'opencode', displayName: 'OpenCode', aliases: ['opencode'], accentColor: '#6E56CF', icon: 'opencode' },
30
+ { slug: 'copilot', displayName: 'Copilot', aliases: ['copilot'], accentColor: '#8957E5', icon: 'copilot' },
31
+ { slug: 'cursor', displayName: 'Cursor', aliases: ['cursor-agent'], accentColor: '#9AA0A6', icon: 'cursor' },
32
+ { slug: 'goose', displayName: 'Goose', aliases: ['goose'], accentColor: '#9A8CFF', icon: 'goose' },
33
+ { slug: 'droid', displayName: 'Droid', aliases: ['droid'], accentColor: '#F59E0B', icon: 'droid' },
34
+ { slug: 'pi', displayName: 'Pi', aliases: ['pi'], accentColor: '#0EA5E9', icon: null },
35
+ { slug: 'auggie', displayName: 'Auggie', aliases: ['auggie'], accentColor: '#16A34A', icon: null },
36
+ { slug: 'hermes', displayName: 'Hermes', aliases: ['hermes'], accentColor: '#8B5CF6', icon: null },
37
+ { slug: 'vibe', displayName: 'Vibe', aliases: ['vibe', 'vibe-acp'], accentColor: '#FF7000', icon: null },
38
+ { slug: 'antigravity', displayName: 'Antigravity', aliases: ['agy', 'antigravity'], accentColor: '#2563EB', icon: null },
39
+ { slug: 'grok', displayName: 'Grok', aliases: ['grok'], accentColor: '#000000', icon: 'grok' },
40
+ { slug: 'qwen', displayName: 'Qwen Code', aliases: ['qwen', 'qwen-code'], accentColor: '#7C3AED', icon: null },
41
+ ];
42
+ const BY_ALIAS = new Map();
43
+ const BY_SLUG = new Map();
44
+ for (const info of AGENTS) {
45
+ BY_SLUG.set(info.slug, info);
46
+ for (const alias of info.aliases)
47
+ BY_ALIAS.set(alias, info);
48
+ }
49
+ export function agentBySlug(slug) {
50
+ if (!slug)
51
+ return null;
52
+ return BY_SLUG.get(slug.trim().toLowerCase()) ?? null;
53
+ }
54
+ export function listAgents() {
55
+ return AGENTS.slice();
56
+ }
57
+ function matchToken(token) {
58
+ return BY_ALIAS.get(token) ?? null;
59
+ }
60
+ /** A `KEY=value` shell environment assignment prefix (`FOO=bar cmd`). */
61
+ function isEnvAssignment(token) {
62
+ const eq = token.indexOf('=');
63
+ if (eq <= 0)
64
+ return false;
65
+ const key = token.slice(0, eq);
66
+ if (!/^[A-Za-z_]/.test(key))
67
+ return false;
68
+ return /^[A-Za-z0-9_]+$/.test(key);
69
+ }
70
+ /**
71
+ * The final path component with trailing script extension stripped.
72
+ * `/usr/bin/claude` → `claude`, `cli.js` → `cli`, `claude.cmd` → `claude`.
73
+ * Splits on both separators so Windows paths resolve the same on Unix.
74
+ */
75
+ function baseStem(token) {
76
+ const trimmed = token.replace(/[/\\]+$/, '');
77
+ const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
78
+ const name = idx >= 0 ? trimmed.slice(idx + 1) : trimmed;
79
+ for (const ext of ['.js', '.mjs', '.cjs', '.ts', '.py', '.rb', '.sh', '.exe', '.cmd', '.bat', '.ps1']) {
80
+ if (name.endsWith(ext))
81
+ return name.slice(0, -ext.length);
82
+ }
83
+ return name;
84
+ }
85
+ /** Whether a launcher basename is an interpreter whose arguments name the real program. */
86
+ function isInterpreter(stem) {
87
+ return ['node', 'nodejs', 'bun', 'deno', 'npx', 'pnpm', 'yarn',
88
+ 'python', 'python3', 'ruby', 'uv', 'uvx', 'env'].includes(stem.toLowerCase());
89
+ }
90
+ function tokenNamesAgent(token, agent) {
91
+ return token.split(/[/\\]/).some((seg) => matchToken(baseStem(seg).toLowerCase()) === agent);
92
+ }
93
+ /**
94
+ * Identify the coding agent a foreground argv is running, or null.
95
+ * `customRules` maps a command basename to an agent slug (personal wrappers);
96
+ * applies to the launcher only and loses to a built-in match on the same name.
97
+ */
98
+ export function detectAgentFromArgv(argv, customRules = {}) {
99
+ let i = 0;
100
+ while (i < argv.length && isEnvAssignment(argv[i]))
101
+ i++;
102
+ const launcher = argv[i];
103
+ if (!launcher)
104
+ return null;
105
+ const launcherStem = baseStem(launcher);
106
+ // Native binary (or built-in match wins over custom rules on the same name)
107
+ const direct = matchToken(launcherStem.toLowerCase());
108
+ if (direct)
109
+ return direct;
110
+ const custom = customRules[launcherStem.toLowerCase()];
111
+ if (custom) {
112
+ const agent = agentBySlug(custom);
113
+ if (agent)
114
+ return agent;
115
+ }
116
+ // Interpreter wrapper: scan the script path / package args it runs
117
+ if (isInterpreter(launcherStem)) {
118
+ for (let j = i + 1; j < argv.length; j++) {
119
+ const arg = argv[j];
120
+ if (arg.startsWith('-'))
121
+ continue;
122
+ for (const seg of arg.split(/[/\\]/)) {
123
+ const hit = matchToken(baseStem(seg).toLowerCase());
124
+ if (hit)
125
+ return hit;
126
+ }
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+ /**
132
+ * Shell-split a raw command line (as captured from ps args) into argv, then
133
+ * detect. Naive tokenization: whitespace split, quotes trimmed — enough for
134
+ * the dominant shapes (`claude --model opus`, `node /path/cli.js --flag`).
135
+ */
136
+ export function detectAgentFromCommand(command, customRules = {}) {
137
+ const argv = command
138
+ .split(/\s+/)
139
+ .map((t) => t.replace(/^["']+|["']+$/g, ''))
140
+ .filter((t) => t.length > 0);
141
+ return detectAgentFromArgv(argv, customRules);
142
+ }
143
+ // ---------------------------------------------------------------------------
144
+ // Resume: rebuild the shell command that resumes a previous agent session by
145
+ // its native session id, carrying the user's original launch flags.
146
+ // ---------------------------------------------------------------------------
147
+ /** Per-agent resume command templates; null = no known resume flag. */
148
+ function resumeCommandFor(agent, sessionId, flags) {
149
+ const f = flags.length > 0 ? ' ' + flags.join(' ') : '';
150
+ switch (agent.slug) {
151
+ case 'claude': return `claude${f} --resume ${sessionId}`;
152
+ // Codex resumes via a subcommand; interactive options follow the positional id
153
+ case 'codex': return `codex resume ${sessionId}${f}`;
154
+ case 'gemini': return `gemini${f} --resume ${sessionId}`;
155
+ case 'opencode': return `opencode${f} --session ${sessionId}`;
156
+ case 'amp': return `amp threads continue ${sessionId}${f}`;
157
+ case 'cursor': return `cursor-agent${f} --resume ${sessionId}`;
158
+ case 'copilot': return `copilot${f} --resume ${sessionId}`;
159
+ case 'grok': return `grok${f} --resume ${sessionId}`;
160
+ default: return null;
161
+ }
162
+ }
163
+ /** Session-targeting flags whose old value must not survive onto a resume. */
164
+ const STALE_SESSION_FLAGS = {
165
+ claude: ['--resume', '-r', '--continue', '-c', '--session-id', '--from-pr'],
166
+ gemini: ['--resume', '-r'],
167
+ cursor: ['--resume', '-r'],
168
+ copilot: ['--resume', '-r', '--continue', '-c'],
169
+ opencode: ['--session', '-s', '--continue', '-c'],
170
+ codex: ['--last'],
171
+ grok: ['--resume', '-r', '--load', '--continue', '-c', '--session-id', '-s',
172
+ '--fork-session', '--worktree', '-w', '--worktree-ref', '--ref'],
173
+ };
174
+ /**
175
+ * The launch-flag tail of argv worth replaying on a resume command, or null
176
+ * to resume bare. Deliberately conservative: anything ambiguous falls back to
177
+ * no flags rather than a corrupted command line.
178
+ * - The tail is everything after the token that names this agent; leading
179
+ * `VAR=value` env assignments are skipped first. No naming token → null.
180
+ * - Stale session-targeting flags are stripped (the new id must win).
181
+ * - Every surviving token must be shell-safe, the first must be a `-` flag,
182
+ * and no two bare words may run consecutively (a bare word is only valid as
183
+ * the value directly behind a flag — anything else is a positional prompt
184
+ * that must not re-submit itself into the resumed session).
185
+ */
186
+ function replayFlags(agent, argv) {
187
+ let start = 0;
188
+ while (start < argv.length && isEnvAssignment(argv[start]))
189
+ start++;
190
+ let named = -1;
191
+ for (let j = start; j < argv.length; j++) {
192
+ if (tokenNamesAgent(argv[j], agent)) {
193
+ named = j;
194
+ break;
195
+ }
196
+ }
197
+ if (named < 0)
198
+ return null;
199
+ const tail = argv.slice(named + 1);
200
+ // A relaunched `codex resume <old-id>`: drop the subcommand and its id
201
+ if (agent.slug === 'codex' && tail[0] === 'resume') {
202
+ tail.shift();
203
+ if (tail.length > 0 && !tail[0].startsWith('-'))
204
+ tail.shift();
205
+ }
206
+ const stale = STALE_SESSION_FLAGS[agent.slug] ?? [];
207
+ for (let j = 0; j < tail.length;) {
208
+ const t = tail[j];
209
+ const isStale = stale.includes(t)
210
+ || stale.some((f) => f.length > 2 && t.startsWith(`${f}=`));
211
+ if (isStale) {
212
+ tail.splice(j, 1);
213
+ if (j < tail.length && !tail[j].startsWith('-'))
214
+ tail.splice(j, 1);
215
+ }
216
+ else {
217
+ j++;
218
+ }
219
+ }
220
+ // Safety gate: plain tokens only, flag-shaped tail
221
+ const safe = (t) => t.length > 0 && /^[A-Za-z0-9_=./,:@+~-]+$/.test(t);
222
+ if (!tail.every(safe))
223
+ return null;
224
+ let prevWasFlag = false;
225
+ for (const t of tail) {
226
+ const isFlag = t.startsWith('-');
227
+ if (!isFlag && !prevWasFlag)
228
+ return null;
229
+ prevWasFlag = isFlag;
230
+ }
231
+ return tail;
232
+ }
233
+ /**
234
+ * The shell command resuming a session, or null when the agent has no known
235
+ * resume flag or the id/flags aren't shell-safe. Ids come from the agent's own
236
+ * hook events but still land on a shell command line — refuse anything that
237
+ * isn't a plain token.
238
+ */
239
+ export function buildResumeCommand(agent, sessionId, launchArgv) {
240
+ if (!sessionId || !/^[A-Za-z0-9._-]+$/.test(sessionId))
241
+ return null;
242
+ const flags = launchArgv ? replayFlags(agent, launchArgv) ?? [] : [];
243
+ return resumeCommandFor(agent, sessionId, flags);
244
+ }
@@ -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();