termdock 1.4.68 → 1.4.70

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 (84) hide show
  1. package/.desktop-dist/main.js +254 -49
  2. package/README.md +13 -0
  3. package/dist/client/assets/{ModelPreview-BgnWlhMf.js → ModelPreview-Cs14IQuQ.js} +1 -1
  4. package/dist/client/assets/{abnfDiagram-VRR7QNED-C3RDwPoE.js → abnfDiagram-VRR7QNED-DuDfdv-6.js} +1 -1
  5. package/dist/client/assets/{arc-n-PrqSp_.js → arc-CSYzGV_2.js} +1 -1
  6. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-D-l8Q2rF.js → architectureDiagram-ZJ3FMSHR-YiZ0HXNi.js} +1 -1
  7. package/dist/client/assets/{blockDiagram-677ZJIJ3-D5b5kvhB.js → blockDiagram-677ZJIJ3-IaQMzmYv.js} +1 -1
  8. package/dist/client/assets/{c4Diagram-LMCZKHZV-CsvG6hAc.js → c4Diagram-LMCZKHZV-BwyuIPRL.js} +1 -1
  9. package/dist/client/assets/channel-BotxtiNE.js +1 -0
  10. package/dist/client/assets/{chunk-2Q5K7J3B-CcTcAD2X.js → chunk-2Q5K7J3B-BtHQYLBT.js} +1 -1
  11. package/dist/client/assets/{chunk-32BRIVSS-c--MtlS9.js → chunk-32BRIVSS-CXrK07sW.js} +1 -1
  12. package/dist/client/assets/{chunk-5VM5RSS4-BCdLlLPD.js → chunk-5VM5RSS4-BfBshn11.js} +1 -1
  13. package/dist/client/assets/{chunk-EX3LRPZG-BXYeFL7Q.js → chunk-EX3LRPZG-BJbilNaf.js} +1 -1
  14. package/dist/client/assets/{chunk-JWPE2WC7-Cakio9E1.js → chunk-JWPE2WC7-wgcmSRlL.js} +1 -1
  15. package/dist/client/assets/{chunk-MOJQB5TN-CR9m_lTv.js → chunk-MOJQB5TN-8VZVUeDI.js} +1 -1
  16. package/dist/client/assets/{chunk-RYQCIY6F-DvPE2m9c.js → chunk-RYQCIY6F-B900YBFa.js} +1 -1
  17. package/dist/client/assets/{chunk-V7JOEXUC-BdHIv-k_.js → chunk-V7JOEXUC-BK4_RLz-.js} +1 -1
  18. package/dist/client/assets/{chunk-VR4S4FIN-CNYv3_OD.js → chunk-VR4S4FIN-Kj5_ycjB.js} +1 -1
  19. package/dist/client/assets/{chunk-XXDRQBXY-Ddhpk5bE.js → chunk-XXDRQBXY-BolvPLRz.js} +1 -1
  20. package/dist/client/assets/classDiagram-OUVF2IWQ-CLWlbTSC.js +1 -0
  21. package/dist/client/assets/classDiagram-v2-EOCWNBFH-CLWlbTSC.js +1 -0
  22. package/dist/client/assets/{cose-bilkent-JH36ORCC-vc2IL5Qo.js → cose-bilkent-JH36ORCC-9_9_SLqv.js} +1 -1
  23. package/dist/client/assets/{cynefin-VYW2F7L2-Cr5U2Llm.js → cynefin-VYW2F7L2-yEpoi3vG.js} +1 -1
  24. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-DaSVhxBB.js → cynefinDiagram-TSTJHNR4-Dy-gvup_.js} +1 -1
  25. package/dist/client/assets/{dagre-VKFMJZFB-B8Ao-OKf.js → dagre-VKFMJZFB-CNjJ5pRK.js} +1 -1
  26. package/dist/client/assets/{diagram-FQU43EPY-a-sl4C9b.js → diagram-FQU43EPY-uZ73Rj3T.js} +1 -1
  27. package/dist/client/assets/{diagram-G47NLZAW-CmezqIfj.js → diagram-G47NLZAW-Cxt7TYZT.js} +1 -1
  28. package/dist/client/assets/{diagram-NH7WQ7WH-rM4l3wBG.js → diagram-NH7WQ7WH-B4TKinuZ.js} +1 -1
  29. package/dist/client/assets/{diagram-OA4YK3LP-DS3rHIQ7.js → diagram-OA4YK3LP-CGSuenHU.js} +1 -1
  30. package/dist/client/assets/{diagram-WEI45ONY-BUyvO5qF.js → diagram-WEI45ONY-DqqzDaUZ.js} +1 -1
  31. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-C6KPNqQ0.js → ebnfDiagram-CCIWWBDH-B2SgXL5w.js} +1 -1
  32. package/dist/client/assets/{erDiagram-Q63AITRT-u9pQXlID.js → erDiagram-Q63AITRT-D-ibkr8y.js} +1 -1
  33. package/dist/client/assets/{flowDiagram-23GEKE2U-h9QkExto.js → flowDiagram-23GEKE2U-BPeyMqr-.js} +1 -1
  34. package/dist/client/assets/{ganttDiagram-NO4QXBWP-C9AXkH3Y.js → ganttDiagram-NO4QXBWP-DcEqEzzu.js} +1 -1
  35. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-CXYfDg8b.js → gitGraphDiagram-IHSO6WYX-DTZ6UCQY.js} +1 -1
  36. package/dist/client/assets/index-DhfIiBLW.css +1 -0
  37. package/dist/client/assets/index-ivnGnbVg.js +451 -0
  38. package/dist/client/assets/{infoDiagram-FWYZ7A6U-B_Kb0Chj.js → infoDiagram-FWYZ7A6U-BHrzVXeX.js} +1 -1
  39. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-B2kT_9-T.js → ishikawaDiagram-FXEZZL3T-CwuoQJku.js} +1 -1
  40. package/dist/client/assets/{journeyDiagram-5HDEW3XC-DdOISfLz.js → journeyDiagram-5HDEW3XC-ZEcy0rDZ.js} +1 -1
  41. package/dist/client/assets/{kanban-definition-HUTT4EX6-CdIIjjXO.js → kanban-definition-HUTT4EX6-BDBawNbr.js} +1 -1
  42. package/dist/client/assets/{linear-CWpzI-ns.js → linear-smYQWzDV.js} +1 -1
  43. package/dist/client/assets/{mermaid.core-BLHPinUj.js → mermaid.core-eJs7Ouvw.js} +4 -4
  44. package/dist/client/assets/{mindmap-definition-LN4V7U3C-qFnTyqQA.js → mindmap-definition-LN4V7U3C-BK1IZ9zg.js} +1 -1
  45. package/dist/client/assets/{pegDiagram-2B236MQR-Bu7ijeZf.js → pegDiagram-2B236MQR-B3-mVNgX.js} +1 -1
  46. package/dist/client/assets/{pieDiagram-ENE6RG2P-BygAm24V.js → pieDiagram-ENE6RG2P-Bkeeyx5l.js} +1 -1
  47. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-3-9rkPqr.js → quadrantDiagram-ABIIQ3AL-DouIklEO.js} +1 -1
  48. package/dist/client/assets/{railroadDiagram-RFXS5EU6-BAsvFjIm.js → railroadDiagram-RFXS5EU6-BB_jXQd2.js} +1 -1
  49. package/dist/client/assets/{requirementDiagram-TGXJPOKE-H-BySumI.js → requirementDiagram-TGXJPOKE-u56IHfiR.js} +1 -1
  50. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-D2PDv2gA.js → sankeyDiagram-HTMAVEWB-eDi2cs5E.js} +1 -1
  51. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-C9pvFRUG.js → sequenceDiagram-DBY2YBRQ-DnAA2xYa.js} +1 -1
  52. package/dist/client/assets/{sizeCapture-X5ZJPWSS-Cx0TrKvi.js → sizeCapture-X5ZJPWSS-BogPui79.js} +1 -1
  53. package/dist/client/assets/{stateDiagram-2N3HPSRC-Ciytmucj.js → stateDiagram-2N3HPSRC--EeXDy2M.js} +1 -1
  54. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Dc-3HKTs.js +1 -0
  55. package/dist/client/assets/{swimlanes-5IMT3BWC-Dwk151xG.js → swimlanes-5IMT3BWC-DZAw7-kY.js} +2 -2
  56. package/dist/client/assets/swimlanesDiagram-G3AALYLV-CI1Wlr0V.js +8 -0
  57. package/dist/client/assets/{timeline-definition-FHXFAJF6-Bym0jj2o.js → timeline-definition-FHXFAJF6-DkcnpB-Y.js} +1 -1
  58. package/dist/client/assets/{vennDiagram-L72KCM5P-CToPJelW.js → vennDiagram-L72KCM5P-BStcDGEf.js} +1 -1
  59. package/dist/client/assets/{wardleyDiagram-EHGQE667-BZ6V3CA9.js → wardleyDiagram-EHGQE667-Cj4CQn1M.js} +1 -1
  60. package/dist/client/assets/{xychartDiagram-FW5EYKEG-D5NzFpxi.js → xychartDiagram-FW5EYKEG-_uBCjai8.js} +1 -1
  61. package/dist/client/index.html +2 -2
  62. package/dist/client/sw.js +1 -1
  63. package/dist/server/agent/autoTitle.js +118 -0
  64. package/dist/server/agent/installers.js +11 -10
  65. package/dist/server/agent/plugins.js +90 -4
  66. package/dist/server/agent/registry.js +1 -0
  67. package/dist/server/agent/session.js +18 -2
  68. package/dist/server/agent/titleNamerCatalog.js +203 -0
  69. package/dist/server/agentHook.js +2 -1
  70. package/dist/server/cli.js +255 -30
  71. package/dist/server/routes/filesystem.js +11 -8
  72. package/dist/server/routes/terminal.js +351 -10
  73. package/dist/server/utils/autoUpdate.js +175 -0
  74. package/dist/server/utils/npmUpdate.js +139 -0
  75. package/dist/server/utils/settings.js +44 -0
  76. package/package.json +1 -1
  77. package/runtime-manifest.json +2 -2
  78. package/dist/client/assets/channel-DqH6WWOA.js +0 -1
  79. package/dist/client/assets/classDiagram-OUVF2IWQ-DbsEzwLQ.js +0 -1
  80. package/dist/client/assets/classDiagram-v2-EOCWNBFH-DbsEzwLQ.js +0 -1
  81. package/dist/client/assets/index-ChhyERKX.css +0 -1
  82. package/dist/client/assets/index-DVo7u2vf.js +0 -442
  83. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-C9GGeb7o.js +0 -1
  84. package/dist/client/assets/swimlanesDiagram-G3AALYLV-BRRLZYN8.js +0 -8
@@ -19,7 +19,7 @@ const ICON_FILE = 'icon.svg';
19
19
  // ---------------------------------------------------------------------------
20
20
  // Manifest schema
21
21
  // ---------------------------------------------------------------------------
22
- export const PLUGIN_MANIFEST_VERSION = 1;
22
+ export const PLUGIN_MANIFEST_VERSION = 2;
23
23
  // ---------------------------------------------------------------------------
24
24
  // Known sentinel event kinds — the set of events our state machine accepts
25
25
  // ---------------------------------------------------------------------------
@@ -33,6 +33,10 @@ const VALID_EVENT_KINDS = new Set([
33
33
  'stop',
34
34
  'session-end',
35
35
  ]);
36
+ const VALID_STATUS_PHASES = new Set(['idle', 'working', 'waiting', 'done']);
37
+ const VALID_STATUS_INDICATORS = new Set(['spinner', 'pulse', 'dot', 'ring', 'badge', 'terminal', 'question']);
38
+ const VALID_STATUS_TONES = new Set(['neutral', 'info', 'success', 'warning', 'danger', 'accent']);
39
+ const STATUS_ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
36
40
  // ---------------------------------------------------------------------------
37
41
  // Slug validation (alphanumeric + hyphens, non-empty)
38
42
  // ---------------------------------------------------------------------------
@@ -53,14 +57,28 @@ function resolveHome(p) {
53
57
  }
54
58
  return p;
55
59
  }
56
- function validateManifest(raw, dir) {
60
+ export const V1_MIGRATION_GUIDE_COMMAND = 'td agent-plugin --json';
61
+ export const V1_MIGRATION_AI_PROMPT = [
62
+ 'Please migrate the attached Termdock Agent plugin manifest from v1 to v2.',
63
+ 'Preserve slug, displayName, aliases, accentColor, iconMode, hooks.target, and resume.',
64
+ 'Add statuses[] entries with id, phase (idle|working|waiting|done), label, indicator, and tone.',
65
+ 'Add a status reference to each relevant hooks.events[] mapping while keeping its core event value.',
66
+ `Use the output of \`${V1_MIGRATION_GUIDE_COMMAND}\` as the authoritative schema.`,
67
+ 'Return only the corrected manifest JSON.',
68
+ ].join(' ');
69
+ export function validateManifest(raw, dir) {
57
70
  const errors = [];
58
71
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
59
72
  return { error: { slug: path.basename(dir), errors: ['manifest.json is not a JSON object'] } };
60
73
  }
61
74
  const m = raw;
62
75
  const version = m.version;
63
- if (typeof version !== 'number' || version !== PLUGIN_MANIFEST_VERSION) {
76
+ const isV1 = version === 1;
77
+ if (isV1) {
78
+ errors.push('manifest v1 is no longer supported; migrate this plugin to manifest v2');
79
+ errors.push(`read the current machine-readable schema with: ${V1_MIGRATION_GUIDE_COMMAND}`);
80
+ }
81
+ else if (typeof version !== 'number' || version !== PLUGIN_MANIFEST_VERSION) {
64
82
  errors.push(`version must be ${PLUGIN_MANIFEST_VERSION}`);
65
83
  }
66
84
  const slug = m.slug;
@@ -84,6 +102,59 @@ function validateManifest(raw, dir) {
84
102
  if (iconMode !== undefined && iconMode !== 'mask' && iconMode !== 'native') {
85
103
  errors.push('iconMode must be "mask" or "native" if provided');
86
104
  }
105
+ // Optional plugin-defined status vocabulary. These decorate the stable
106
+ // idle/working/waiting/done phases instead of replacing their semantics.
107
+ let statuses;
108
+ const statusIds = new Set();
109
+ if (m.statuses !== undefined) {
110
+ if (!Array.isArray(m.statuses) || m.statuses.length === 0) {
111
+ errors.push('statuses must be a non-empty array if present');
112
+ }
113
+ else {
114
+ const validStatuses = [];
115
+ for (let i = 0; i < m.statuses.length; i++) {
116
+ const rawStatus = m.statuses[i];
117
+ if (!rawStatus || typeof rawStatus !== 'object' || Array.isArray(rawStatus)) {
118
+ errors.push(`statuses[${i}] is not an object`);
119
+ continue;
120
+ }
121
+ const status = rawStatus;
122
+ const id = status.id;
123
+ const phase = status.phase;
124
+ const label = status.label;
125
+ const indicator = status.indicator;
126
+ const tone = status.tone;
127
+ if (typeof id !== 'string' || !STATUS_ID_RE.test(id)) {
128
+ errors.push(`statuses[${i}].id must be lowercase alphanumeric with hyphens (max 40 chars)`);
129
+ continue;
130
+ }
131
+ if (statusIds.has(id))
132
+ errors.push(`statuses[${i}].id duplicates "${id}"`);
133
+ statusIds.add(id);
134
+ if (typeof phase !== 'string' || !VALID_STATUS_PHASES.has(phase)) {
135
+ errors.push(`statuses[${i}].phase must be one of: ${[...VALID_STATUS_PHASES].join(', ')}`);
136
+ }
137
+ if (typeof label !== 'string' || label.trim().length === 0 || label.length > 80) {
138
+ errors.push(`statuses[${i}].label must be a non-empty string (max 80 chars)`);
139
+ }
140
+ if (indicator !== undefined && (typeof indicator !== 'string' || !VALID_STATUS_INDICATORS.has(indicator))) {
141
+ errors.push(`statuses[${i}].indicator must be one of: ${[...VALID_STATUS_INDICATORS].join(', ')}`);
142
+ }
143
+ if (tone !== undefined && (typeof tone !== 'string' || !VALID_STATUS_TONES.has(tone))) {
144
+ errors.push(`statuses[${i}].tone must be one of: ${[...VALID_STATUS_TONES].join(', ')}`);
145
+ }
146
+ validStatuses.push({
147
+ id,
148
+ phase: phase,
149
+ label: typeof label === 'string' ? label.trim() : '',
150
+ indicator: indicator,
151
+ tone: tone,
152
+ });
153
+ }
154
+ if (validStatuses.length > 0)
155
+ statuses = validStatuses;
156
+ }
157
+ }
87
158
  // Optional hooks
88
159
  let hooks;
89
160
  if (m.hooks !== undefined) {
@@ -125,11 +196,16 @@ function validateManifest(raw, dir) {
125
196
  if (timeout !== undefined && (typeof timeout !== 'number' || timeout <= 0)) {
126
197
  errors.push(`hooks.events[${i}].timeout must be a positive number if provided`);
127
198
  }
199
+ const status = evt.status;
200
+ if (status !== undefined && (typeof status !== 'string' || !statusIds.has(status))) {
201
+ errors.push(`hooks.events[${i}].status must reference a declared statuses[].id`);
202
+ }
128
203
  validEvents.push({
129
204
  hook: hook,
130
205
  event: event,
131
206
  matcher: typeof matcher === 'string' ? matcher : undefined,
132
207
  timeout: typeof timeout === 'number' ? timeout : undefined,
208
+ status: typeof status === 'string' ? status : undefined,
133
209
  });
134
210
  }
135
211
  if (validEvents.length > 0) {
@@ -161,7 +237,16 @@ function validateManifest(raw, dir) {
161
237
  }
162
238
  }
163
239
  if (errors.length > 0) {
164
- return { error: { slug: typeof slug === 'string' ? slug : path.basename(dir), errors } };
240
+ return {
241
+ error: {
242
+ slug: typeof slug === 'string' ? slug : path.basename(dir),
243
+ errors,
244
+ code: isV1 ? 'AGENT_PLUGIN_MANIFEST_V1_UNSUPPORTED' : undefined,
245
+ migration: isV1
246
+ ? { guideCommand: V1_MIGRATION_GUIDE_COMMAND, aiPrompt: V1_MIGRATION_AI_PROMPT }
247
+ : undefined,
248
+ },
249
+ };
165
250
  }
166
251
  return {
167
252
  manifest: {
@@ -171,6 +256,7 @@ function validateManifest(raw, dir) {
171
256
  aliases: aliases.map((a) => a.trim().toLowerCase()),
172
257
  accentColor: accentColor,
173
258
  iconMode: iconMode,
259
+ statuses,
174
260
  hooks,
175
261
  resume: resumeConfig,
176
262
  },
@@ -278,6 +278,7 @@ export function registerPluginAgents(plugins) {
278
278
  isPlugin: true,
279
279
  iconMode: manifest.iconMode,
280
280
  iconVersion: p.iconMtime || undefined,
281
+ statuses: manifest.statuses,
281
282
  };
282
283
  BY_SLUG.set(info.slug, info);
283
284
  for (const alias of info.aliases)
@@ -27,6 +27,7 @@ export function defaultAgentSessionState() {
27
27
  agentCwd: null,
28
28
  activity: 0,
29
29
  reviewed: true,
30
+ presentation: null,
30
31
  };
31
32
  }
32
33
  /**
@@ -39,6 +40,9 @@ export function applyAgentEvent(state, ev) {
39
40
  state.sessionId = ev.sessionId;
40
41
  if (ev.cwd)
41
42
  state.agentCwd = ev.cwd;
43
+ // A custom state is scoped to one hook edge; never let it leak into the
44
+ // next core lifecycle event that did not explicitly declare it.
45
+ state.presentation = null;
42
46
  switch (ev.kind) {
43
47
  case 'session-start':
44
48
  state.status = 'idle';
@@ -100,6 +104,10 @@ export function applyAgentEvent(state, ev) {
100
104
  state.agentCwd = null;
101
105
  break;
102
106
  }
107
+ if (ev.status) {
108
+ state.status = ev.status.phase;
109
+ state.presentation = ev.status;
110
+ }
103
111
  }
104
112
  function nonEmpty(s) {
105
113
  return typeof s === 'string' && s.trim().length > 0 ? s : null;
@@ -132,12 +140,18 @@ export function parseAgentEvent(payload) {
132
140
  const eventName = nonEmpty(w.event);
133
141
  if (!eventName || !EVENT_KINDS.has(eventName))
134
142
  return null;
143
+ const agent = agentBySlug(nonEmpty(w.agent));
144
+ const statusId = nonEmpty(w.status);
145
+ const status = statusId
146
+ ? agent?.statuses?.find((definition) => definition.id === statusId) ?? null
147
+ : null;
135
148
  return {
136
- agent: agentBySlug(nonEmpty(w.agent)),
149
+ agent,
137
150
  kind: eventName,
138
151
  sessionId: nonEmpty(w.session_id),
139
152
  message: nonEmpty(w.message),
140
153
  cwd: nonEmpty(w.cwd),
154
+ status,
141
155
  };
142
156
  }
143
157
  /**
@@ -146,7 +160,7 @@ export function parseAgentEvent(payload) {
146
160
  * `payloadJson` is the hook's raw stdin JSON from the agent; fields are lifted
147
161
  * into the sentinel body (snake_case, with camelCase aliases for Grok).
148
162
  */
149
- export function buildHookSequence(agent, event, stdinJson) {
163
+ export function buildHookSequence(agent, event, stdinJson, statusId) {
150
164
  let payload = {};
151
165
  try {
152
166
  const parsed = JSON.parse(stdinJson);
@@ -159,6 +173,8 @@ export function buildHookSequence(agent, event, stdinJson) {
159
173
  agent,
160
174
  event,
161
175
  };
176
+ if (statusId)
177
+ body.status = statusId;
162
178
  for (const [key, alias] of [['session_id', 'sessionId'], ['message', 'message'], ['cwd', 'cwd']]) {
163
179
  const v = nonEmpty(payload[key]) ?? nonEmpty(payload[alias]);
164
180
  if (v)
@@ -0,0 +1,203 @@
1
+ import { execFile, spawn } from 'child_process';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { promisify } from 'util';
6
+ const execFileAsync = promisify(execFile);
7
+ const CACHE_FRESH_MS = 24 * 60 * 60_000;
8
+ const PROBE_TIMEOUT_MS = 10_000;
9
+ const CACHE_FILE = path.join(os.homedir(), '.termdock', 'title-namer-catalog.json');
10
+ let cached = null;
11
+ let refreshPromise = null;
12
+ function normalizeCachedCatalog(input) {
13
+ if (!input || typeof input !== 'object')
14
+ return null;
15
+ const doc = input;
16
+ if (doc.version !== 1 || typeof doc.updatedAt !== 'number' || !Array.isArray(doc.value))
17
+ return null;
18
+ const value = doc.value.flatMap((entry) => {
19
+ if (!entry || typeof entry !== 'object')
20
+ return [];
21
+ const candidate = entry;
22
+ if (candidate.slug !== 'codex' && candidate.slug !== 'claude')
23
+ return [];
24
+ if (!Array.isArray(candidate.models))
25
+ return [];
26
+ const models = candidate.models.filter((model) => Boolean(model
27
+ && typeof model.id === 'string'
28
+ && typeof model.displayName === 'string'
29
+ && typeof model.description === 'string'
30
+ && typeof model.isDefault === 'boolean'));
31
+ return [{
32
+ slug: candidate.slug,
33
+ displayName: typeof candidate.displayName === 'string' ? candidate.displayName : candidate.slug,
34
+ available: candidate.available === true,
35
+ models,
36
+ recommendedModel: typeof candidate.recommendedModel === 'string'
37
+ ? candidate.recommendedModel
38
+ : recommendTitleModel(models),
39
+ }];
40
+ });
41
+ return value.length > 0 ? { version: 1, updatedAt: doc.updatedAt, value } : null;
42
+ }
43
+ async function loadPersistentCache() {
44
+ try {
45
+ return normalizeCachedCatalog(JSON.parse(await fs.promises.readFile(CACHE_FILE, 'utf8')));
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ async function savePersistentCache(doc) {
52
+ const directory = path.dirname(CACHE_FILE);
53
+ const temporary = `${CACHE_FILE}.${process.pid}.tmp`;
54
+ await fs.promises.mkdir(directory, { recursive: true });
55
+ await fs.promises.writeFile(temporary, JSON.stringify(doc, null, 2), 'utf8');
56
+ await fs.promises.rename(temporary, CACHE_FILE);
57
+ }
58
+ export function recommendTitleModel(models) {
59
+ return models.find((model) => /affordable|cost[- ]efficient|cost[- ]sensitive/i.test(model.description))?.id
60
+ ?? models.find((model) => model.isDefault)?.id
61
+ ?? null;
62
+ }
63
+ async function listCodexModels() {
64
+ return new Promise((resolve, reject) => {
65
+ const child = spawn('codex', ['app-server', '--stdio'], {
66
+ stdio: ['pipe', 'pipe', 'ignore'],
67
+ env: { ...process.env, NO_COLOR: '1', TERM: 'dumb' },
68
+ });
69
+ let buffer = '';
70
+ let settled = false;
71
+ const finish = (models, error) => {
72
+ if (settled)
73
+ return;
74
+ settled = true;
75
+ clearTimeout(timer);
76
+ child.kill();
77
+ if (error)
78
+ reject(error);
79
+ else
80
+ resolve(models ?? []);
81
+ };
82
+ const timer = setTimeout(() => finish(undefined, new Error('Codex model discovery timed out')), PROBE_TIMEOUT_MS);
83
+ timer.unref?.();
84
+ child.on('error', (error) => finish(undefined, error));
85
+ child.stdout.on('data', (chunk) => {
86
+ buffer += chunk.toString();
87
+ for (;;) {
88
+ const newline = buffer.indexOf('\n');
89
+ if (newline < 0)
90
+ break;
91
+ const line = buffer.slice(0, newline).trim();
92
+ buffer = buffer.slice(newline + 1);
93
+ if (!line)
94
+ continue;
95
+ let message;
96
+ try {
97
+ message = JSON.parse(line);
98
+ }
99
+ catch {
100
+ continue;
101
+ }
102
+ if (message.id === 1) {
103
+ child.stdin.write(`${JSON.stringify({ method: 'initialized', params: {} })}\n`);
104
+ child.stdin.write(`${JSON.stringify({ id: 2, method: 'model/list', params: { limit: 100 } })}\n`);
105
+ }
106
+ else if (message.id === 2) {
107
+ const data = message.result?.data;
108
+ const models = Array.isArray(data) ? data.flatMap((entry) => {
109
+ if (!entry || typeof entry !== 'object')
110
+ return [];
111
+ const model = entry;
112
+ if (typeof model.model !== 'string' || typeof model.displayName !== 'string')
113
+ return [];
114
+ return [{
115
+ id: model.model,
116
+ displayName: model.displayName,
117
+ description: typeof model.description === 'string' ? model.description : '',
118
+ isDefault: model.isDefault === true,
119
+ }];
120
+ }) : [];
121
+ finish(models);
122
+ }
123
+ }
124
+ });
125
+ child.stdin.write(`${JSON.stringify({
126
+ id: 1,
127
+ method: 'initialize',
128
+ params: { clientInfo: { name: 'termdock', version: '1' } },
129
+ })}\n`);
130
+ });
131
+ }
132
+ export function parseClaudeSupportedModels(output) {
133
+ const match = output.match(/supported (?:API )?model names are ([^\n]+?)(?:,?\s+but you passed|$)/i);
134
+ if (!match)
135
+ return [];
136
+ return match[1]
137
+ .split(/,|\band\b/i)
138
+ .map((model) => model.trim().replace(/^["'`]+|["'`]+$/g, ''))
139
+ .filter((model) => /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,119}$/.test(model))
140
+ .map((model) => ({ id: model, displayName: model, description: '', isDefault: false }));
141
+ }
142
+ async function listClaudeModels() {
143
+ try {
144
+ await execFileAsync('claude', ['--model', '__termdock_model_probe__', '--no-session-persistence', '-p', 'x'], {
145
+ timeout: PROBE_TIMEOUT_MS,
146
+ maxBuffer: 128 * 1024,
147
+ env: { ...process.env, NO_COLOR: '1', TERM: 'dumb' },
148
+ });
149
+ return [];
150
+ }
151
+ catch (error) {
152
+ const candidate = error;
153
+ if (candidate.code === 'ENOENT')
154
+ throw error;
155
+ return parseClaudeSupportedModels(`${candidate.stdout ?? ''}\n${candidate.stderr ?? ''}`);
156
+ }
157
+ }
158
+ async function refreshTitleNamerCatalog() {
159
+ if (refreshPromise)
160
+ return refreshPromise;
161
+ refreshPromise = (async () => {
162
+ const [codex, claude] = await Promise.allSettled([listCodexModels(), listClaudeModels()]);
163
+ const previousCodex = cached?.value.find((namer) => namer.slug === 'codex');
164
+ const previousClaude = cached?.value.find((namer) => namer.slug === 'claude');
165
+ const codexModels = codex.status === 'fulfilled' ? codex.value : previousCodex?.models ?? [];
166
+ const claudeModels = claude.status === 'fulfilled' ? claude.value : previousClaude?.models ?? [];
167
+ const value = [
168
+ {
169
+ slug: 'codex',
170
+ displayName: 'Codex',
171
+ available: codex.status === 'fulfilled' || previousCodex?.available === true,
172
+ models: codexModels,
173
+ recommendedModel: recommendTitleModel(codexModels),
174
+ },
175
+ {
176
+ slug: 'claude',
177
+ displayName: 'Claude Code',
178
+ available: claude.status === 'fulfilled' || previousClaude?.available === true,
179
+ models: claudeModels,
180
+ recommendedModel: recommendTitleModel(claudeModels),
181
+ },
182
+ ];
183
+ cached = { version: 1, updatedAt: Date.now(), value };
184
+ await savePersistentCache(cached).catch(() => undefined);
185
+ return value;
186
+ })().finally(() => {
187
+ refreshPromise = null;
188
+ });
189
+ return refreshPromise;
190
+ }
191
+ export async function getTitleNamerCatalog(force = false) {
192
+ if (force)
193
+ return refreshTitleNamerCatalog();
194
+ if (!cached)
195
+ cached = await loadPersistentCache();
196
+ if (!cached)
197
+ return refreshTitleNamerCatalog();
198
+ if (Date.now() - cached.updatedAt > CACHE_FRESH_MS) {
199
+ // Stale-while-revalidate: callers get the last known catalog immediately.
200
+ void refreshTitleNamerCatalog();
201
+ }
202
+ return cached.value;
203
+ }
@@ -145,6 +145,7 @@ function main() {
145
145
  args.shift();
146
146
  let agent = args[0] ?? '';
147
147
  const eventArg = args[1] ?? '';
148
+ const statusArg = args[2] ?? '';
148
149
  if (!agent || !eventArg)
149
150
  return;
150
151
  if (process.env[GROK_HOOK_ENV])
@@ -164,7 +165,7 @@ function main() {
164
165
  const event = effectiveEvent(agent, eventArg, input);
165
166
  if (!event)
166
167
  return;
167
- writeToControllingTty(buildHookSequence(agent, event, input), inTmuxPane);
168
+ writeToControllingTty(buildHookSequence(agent, event, input, statusArg || undefined), inTmuxPane);
168
169
  }
169
170
  catch {
170
171
  // Never break the agent's flow.