termdock 1.4.70 → 1.4.71

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 (76) hide show
  1. package/dist/client/assets/{ModelPreview-Cs14IQuQ.js → ModelPreview-CG6vLwyw.js} +1 -1
  2. package/dist/client/assets/{abnfDiagram-VRR7QNED-DuDfdv-6.js → abnfDiagram-VRR7QNED-CrXEZLWX.js} +1 -1
  3. package/dist/client/assets/{arc-CSYzGV_2.js → arc-DbKGR7Ex.js} +1 -1
  4. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-YiZ0HXNi.js → architectureDiagram-ZJ3FMSHR-PF5BoUbI.js} +1 -1
  5. package/dist/client/assets/{blockDiagram-677ZJIJ3-IaQMzmYv.js → blockDiagram-677ZJIJ3-D1gRzVWB.js} +1 -1
  6. package/dist/client/assets/{c4Diagram-LMCZKHZV-BwyuIPRL.js → c4Diagram-LMCZKHZV-CwvOLync.js} +1 -1
  7. package/dist/client/assets/channel-JTEuKVy6.js +1 -0
  8. package/dist/client/assets/{chunk-2Q5K7J3B-BtHQYLBT.js → chunk-2Q5K7J3B-BeRdP-xP.js} +1 -1
  9. package/dist/client/assets/{chunk-32BRIVSS-CXrK07sW.js → chunk-32BRIVSS-CrqR5rvl.js} +1 -1
  10. package/dist/client/assets/{chunk-5VM5RSS4-BfBshn11.js → chunk-5VM5RSS4-CjbUr-ko.js} +1 -1
  11. package/dist/client/assets/{chunk-EX3LRPZG-BJbilNaf.js → chunk-EX3LRPZG-BVEk3Wma.js} +1 -1
  12. package/dist/client/assets/{chunk-JWPE2WC7-wgcmSRlL.js → chunk-JWPE2WC7-WitJe8kR.js} +1 -1
  13. package/dist/client/assets/{chunk-MOJQB5TN-8VZVUeDI.js → chunk-MOJQB5TN-BCewlLYk.js} +1 -1
  14. package/dist/client/assets/{chunk-RYQCIY6F-B900YBFa.js → chunk-RYQCIY6F-CoP0AA4G.js} +1 -1
  15. package/dist/client/assets/{chunk-V7JOEXUC-BK4_RLz-.js → chunk-V7JOEXUC-CSZrnmp_.js} +1 -1
  16. package/dist/client/assets/{chunk-VR4S4FIN-Kj5_ycjB.js → chunk-VR4S4FIN-BB-7zz-9.js} +1 -1
  17. package/dist/client/assets/{chunk-XXDRQBXY-BolvPLRz.js → chunk-XXDRQBXY-CcZZlCik.js} +1 -1
  18. package/dist/client/assets/classDiagram-OUVF2IWQ-BiFJB_5s.js +1 -0
  19. package/dist/client/assets/classDiagram-v2-EOCWNBFH-BiFJB_5s.js +1 -0
  20. package/dist/client/assets/{cose-bilkent-JH36ORCC-9_9_SLqv.js → cose-bilkent-JH36ORCC-DcmzUDwh.js} +1 -1
  21. package/dist/client/assets/{cynefin-VYW2F7L2-yEpoi3vG.js → cynefin-VYW2F7L2-C1XlDVoJ.js} +1 -1
  22. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-Dy-gvup_.js → cynefinDiagram-TSTJHNR4-zY6Aet2S.js} +1 -1
  23. package/dist/client/assets/{dagre-VKFMJZFB-CNjJ5pRK.js → dagre-VKFMJZFB-DqGTi6zR.js} +1 -1
  24. package/dist/client/assets/{diagram-FQU43EPY-uZ73Rj3T.js → diagram-FQU43EPY-DStcsU7S.js} +1 -1
  25. package/dist/client/assets/{diagram-G47NLZAW-Cxt7TYZT.js → diagram-G47NLZAW-Cs6IMNip.js} +1 -1
  26. package/dist/client/assets/{diagram-NH7WQ7WH-B4TKinuZ.js → diagram-NH7WQ7WH-yA2bpWN7.js} +1 -1
  27. package/dist/client/assets/{diagram-OA4YK3LP-CGSuenHU.js → diagram-OA4YK3LP-Dzf9M0PI.js} +1 -1
  28. package/dist/client/assets/{diagram-WEI45ONY-DqqzDaUZ.js → diagram-WEI45ONY-D75lpQKA.js} +1 -1
  29. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-B2SgXL5w.js → ebnfDiagram-CCIWWBDH-CkQvIpWy.js} +1 -1
  30. package/dist/client/assets/{erDiagram-Q63AITRT-D-ibkr8y.js → erDiagram-Q63AITRT-Ca00Vjxz.js} +1 -1
  31. package/dist/client/assets/{flowDiagram-23GEKE2U-BPeyMqr-.js → flowDiagram-23GEKE2U-CGrnSTEp.js} +1 -1
  32. package/dist/client/assets/{ganttDiagram-NO4QXBWP-DcEqEzzu.js → ganttDiagram-NO4QXBWP-0SHfOIP4.js} +1 -1
  33. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-DTZ6UCQY.js → gitGraphDiagram-IHSO6WYX-BeigYWvs.js} +1 -1
  34. package/dist/client/assets/{index-ivnGnbVg.js → index-D5PKdbhW.js} +58 -58
  35. package/dist/client/assets/{infoDiagram-FWYZ7A6U-BHrzVXeX.js → infoDiagram-FWYZ7A6U-CiY1-xjN.js} +1 -1
  36. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-CwuoQJku.js → ishikawaDiagram-FXEZZL3T-CmtDjUm_.js} +1 -1
  37. package/dist/client/assets/{journeyDiagram-5HDEW3XC-ZEcy0rDZ.js → journeyDiagram-5HDEW3XC-B7qOr9LY.js} +1 -1
  38. package/dist/client/assets/{kanban-definition-HUTT4EX6-BDBawNbr.js → kanban-definition-HUTT4EX6-DO2P2Um3.js} +1 -1
  39. package/dist/client/assets/{linear-smYQWzDV.js → linear-B832hJt0.js} +1 -1
  40. package/dist/client/assets/{mermaid.core-eJs7Ouvw.js → mermaid.core-CsAzdiCU.js} +4 -4
  41. package/dist/client/assets/{mindmap-definition-LN4V7U3C-BK1IZ9zg.js → mindmap-definition-LN4V7U3C-HI__vXhQ.js} +1 -1
  42. package/dist/client/assets/{pegDiagram-2B236MQR-B3-mVNgX.js → pegDiagram-2B236MQR--rZIwjXq.js} +1 -1
  43. package/dist/client/assets/{pieDiagram-ENE6RG2P-Bkeeyx5l.js → pieDiagram-ENE6RG2P-D9jPf5x9.js} +1 -1
  44. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-DouIklEO.js → quadrantDiagram-ABIIQ3AL-DTCP5ji2.js} +1 -1
  45. package/dist/client/assets/{railroadDiagram-RFXS5EU6-BB_jXQd2.js → railroadDiagram-RFXS5EU6-D2Gspvrl.js} +1 -1
  46. package/dist/client/assets/{requirementDiagram-TGXJPOKE-u56IHfiR.js → requirementDiagram-TGXJPOKE-DEJC02IW.js} +1 -1
  47. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-eDi2cs5E.js → sankeyDiagram-HTMAVEWB-opOhKPap.js} +1 -1
  48. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-DnAA2xYa.js → sequenceDiagram-DBY2YBRQ-_sz2CRAG.js} +1 -1
  49. package/dist/client/assets/{sizeCapture-X5ZJPWSS-BogPui79.js → sizeCapture-X5ZJPWSS-D0Q2QkYF.js} +1 -1
  50. package/dist/client/assets/{stateDiagram-2N3HPSRC--EeXDy2M.js → stateDiagram-2N3HPSRC-VgeSQTtW.js} +1 -1
  51. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-BVh2FRO-.js +1 -0
  52. package/dist/client/assets/{swimlanes-5IMT3BWC-DZAw7-kY.js → swimlanes-5IMT3BWC-ClvBvSl0.js} +2 -2
  53. package/dist/client/assets/swimlanesDiagram-G3AALYLV-C17V-kgm.js +8 -0
  54. package/dist/client/assets/{timeline-definition-FHXFAJF6-DkcnpB-Y.js → timeline-definition-FHXFAJF6-BXNsrECV.js} +1 -1
  55. package/dist/client/assets/{vennDiagram-L72KCM5P-BStcDGEf.js → vennDiagram-L72KCM5P-BgwiqZd-.js} +1 -1
  56. package/dist/client/assets/{wardleyDiagram-EHGQE667-Cj4CQn1M.js → wardleyDiagram-EHGQE667-DiqN5ERx.js} +1 -1
  57. package/dist/client/assets/{xychartDiagram-FW5EYKEG-_uBCjai8.js → xychartDiagram-FW5EYKEG-DQjFVqC6.js} +1 -1
  58. package/dist/client/index.html +1 -1
  59. package/dist/client/sw.js +1 -1
  60. package/dist/server/agent/autoTitle.js +14 -37
  61. package/dist/server/agent/installers.js +98 -15
  62. package/dist/server/agent/plugins.js +45 -0
  63. package/dist/server/agent/registry.js +1 -1
  64. package/dist/server/agent/session.js +10 -1
  65. package/dist/server/agent/titleNamerCatalog.js +100 -2
  66. package/dist/server/agentHook.js +47 -0
  67. package/dist/server/cli.js +20 -0
  68. package/dist/server/routes/terminal.js +37 -10
  69. package/dist/server/utils/settings.js +4 -5
  70. package/package.json +1 -1
  71. package/runtime-manifest.json +2 -2
  72. package/dist/client/assets/channel-BotxtiNE.js +0 -1
  73. package/dist/client/assets/classDiagram-OUVF2IWQ-CLWlbTSC.js +0 -1
  74. package/dist/client/assets/classDiagram-v2-EOCWNBFH-CLWlbTSC.js +0 -1
  75. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Dc-3HKTs.js +0 -1
  76. package/dist/client/assets/swimlanesDiagram-G3AALYLV-CI1Wlr0V.js +0 -8
@@ -16,7 +16,7 @@
16
16
  import fs from 'node:fs';
17
17
  import os from 'node:os';
18
18
  import path from 'node:path';
19
- import { execFile } from 'node:child_process';
19
+ import { execFile, spawn } from 'node:child_process';
20
20
  import { agentBySlug, isPluginAgent } from './registry.js';
21
21
  import { loadPlugins, resolveHookTarget } from './plugins.js';
22
22
  export const HOOK_AGENTS = ['claude', 'codex', 'copilot', 'opencode', 'pi', 'grok', 'kimi'];
@@ -121,13 +121,14 @@ const CLAUDE_HOOK_EVENTS = [
121
121
  ['Stop', 'stop'],
122
122
  ['SessionEnd', 'session-end'],
123
123
  ];
124
- /** Codex's hook events (~/.codex/hooks.json, Claude-shaped). Codex is
125
- * turn-level only: no Notification hook, and no SessionEnd — the pane's
126
- * foreground detection clears the badge when Codex exits. */
124
+ /** Codex's native hooks.json event vocabulary. */
127
125
  const CODEX_HOOK_EVENTS = [
128
126
  ['SessionStart', 'session-start'],
129
127
  ['UserPromptSubmit', 'prompt-submit'],
128
+ ['PermissionRequest', 'permission-request'],
129
+ ['PostToolUse', 'tool-complete'],
130
130
  ['Stop', 'stop'],
131
+ ['SessionEnd', 'session-end'],
131
132
  ];
132
133
  /**
133
134
  * Kimi Code's hook events (~/.kimi-code/config.toml `[[hooks]]` entries, TOML).
@@ -620,6 +621,73 @@ export function hooksState(agent) {
620
621
  return 'not-installed';
621
622
  return ownedFileState(file, expected, marker(agent));
622
623
  }
624
+ let codexRuntimeStateCache = null;
625
+ /** Query Codex's own hook registry: a structurally present hook can still be
626
+ * quarantined until the user approves its current hash in `/hooks`. */
627
+ async function codexRuntimeHooksState() {
628
+ const structural = hooksState('codex');
629
+ if (structural !== 'installed')
630
+ return structural;
631
+ if (codexRuntimeStateCache && Date.now() - codexRuntimeStateCache.at < 30_000) {
632
+ return codexRuntimeStateCache.state;
633
+ }
634
+ const state = await new Promise((resolve) => {
635
+ const child = spawn('codex', ['app-server', '--stdio'], {
636
+ stdio: ['pipe', 'pipe', 'ignore'],
637
+ env: { ...process.env, NO_COLOR: '1', TERM: 'dumb' },
638
+ });
639
+ let buffer = '';
640
+ let settled = false;
641
+ const finish = (value) => {
642
+ if (settled)
643
+ return;
644
+ settled = true;
645
+ clearTimeout(timer);
646
+ child.kill();
647
+ resolve(value);
648
+ };
649
+ const timer = setTimeout(() => finish(structural), 5_000);
650
+ timer.unref?.();
651
+ child.on('error', () => finish(structural));
652
+ child.stdout.on('data', (chunk) => {
653
+ buffer += chunk.toString();
654
+ for (;;) {
655
+ const newline = buffer.indexOf('\n');
656
+ if (newline < 0)
657
+ break;
658
+ const line = buffer.slice(0, newline).trim();
659
+ buffer = buffer.slice(newline + 1);
660
+ if (!line)
661
+ continue;
662
+ let message;
663
+ try {
664
+ message = JSON.parse(line);
665
+ }
666
+ catch {
667
+ continue;
668
+ }
669
+ if (message.id === 1) {
670
+ child.stdin.write(`${JSON.stringify({ method: 'initialized', params: {} })}\n`);
671
+ child.stdin.write(`${JSON.stringify({ id: 2, method: 'hooks/list', params: { cwds: [process.cwd()] } })}\n`);
672
+ }
673
+ else if (message.id === 2) {
674
+ const ours = (message.result?.data ?? [])
675
+ .flatMap((entry) => entry.hooks ?? [])
676
+ .filter((hook) => typeof hook.command === 'string' && hook.command.includes(marker('codex')));
677
+ if (ours.length < CODEX_HOOK_EVENTS.length)
678
+ finish('outdated');
679
+ else if (ours.some((hook) => hook.enabled === false || !['trusted', 'managed'].includes(hook.trustStatus ?? '')))
680
+ finish('needs-approval');
681
+ else
682
+ finish('installed');
683
+ }
684
+ }
685
+ });
686
+ child.stdin.write(`${JSON.stringify({ id: 1, method: 'initialize', params: { clientInfo: { name: 'termdock', version: '1' } } })}\n`);
687
+ });
688
+ codexRuntimeStateCache = { at: Date.now(), state };
689
+ return state;
690
+ }
623
691
  export function listHookAgents() {
624
692
  return HOOK_AGENTS.map((slug) => ({
625
693
  slug,
@@ -641,9 +709,10 @@ export async function installHooks(agent) {
641
709
  }
642
710
  if (agent === 'codex') {
643
711
  hookMapInstall(file, agent, CODEX_HOOK_EVENTS);
712
+ codexRuntimeStateCache = null;
644
713
  try {
645
714
  await enableCodexHooksFeature();
646
- return { summary: 'Installed', devMode };
715
+ return { summary: 'Installed — open /hooks in Codex and approve any new Termdock hooks', devMode };
647
716
  }
648
717
  catch (error) {
649
718
  return {
@@ -668,6 +737,8 @@ export async function installHooks(agent) {
668
737
  export async function uninstallHooks(agent) {
669
738
  const file = targetPath(agent);
670
739
  if (agent === 'claude' || agent === 'codex') {
740
+ if (agent === 'codex')
741
+ codexRuntimeStateCache = null;
671
742
  return hookMapUninstall(file, agent);
672
743
  }
673
744
  if (agent === 'kimi') {
@@ -700,7 +771,7 @@ function hookCommandForPlugin(slug, event, status) {
700
771
  * List all agents with hook support — both built-in and plugin-defined.
701
772
  * Plugin agents appear after built-in agents.
702
773
  */
703
- export function listAllHookAgents() {
774
+ export async function listAllHookAgents() {
704
775
  const builtIn = HOOK_AGENTS.map((slug) => {
705
776
  const agent = agentBySlug(slug);
706
777
  return {
@@ -727,6 +798,9 @@ export function listAllHookAgents() {
727
798
  iconVersion: agent?.iconVersion ?? null,
728
799
  };
729
800
  });
801
+ const codex = builtIn.find((entry) => entry.slug === 'codex');
802
+ if (codex)
803
+ codex.state = await codexRuntimeHooksState();
730
804
  return [...builtIn, ...pluginAgents];
731
805
  }
732
806
  export function hooksStateForSlug(slug) {
@@ -763,7 +837,7 @@ export async function uninstallHooksForSlug(slug) {
763
837
  // Generic hookmap logic (shared with built-in Claude/Codex + all plugins)
764
838
  // ---------------------------------------------------------------------------
765
839
  /** Generic state check for a hookmap-style hooks file. */
766
- function pluginHooksState(slug, file, events) {
840
+ export function pluginHooksState(slug, file, events) {
767
841
  const root = readJsonObject(file);
768
842
  if (!root)
769
843
  return 'not-installed';
@@ -771,16 +845,22 @@ function pluginHooksState(slug, file, events) {
771
845
  const hooks = root.hooks;
772
846
  let any = false;
773
847
  let complete = true;
774
- for (const [hookEvent, sentinel, , , status] of events) {
848
+ for (const [hookEvent, sentinel, matcher, timeout, status] of events) {
775
849
  const list = hooks && typeof hooks === 'object'
776
850
  ? hooks[hookEvent]
777
851
  : null;
778
852
  const ours = Array.isArray(list)
779
- ? list.map((entry) => markerCommand(entry, mark)).find((c) => c !== null) ?? null
780
- : null;
781
- if (ours !== null) {
853
+ ? list.find((entry) => markerCommand(entry, mark) !== null)
854
+ : undefined;
855
+ if (ours !== undefined) {
782
856
  any = true;
783
- if (ours !== hookCommandForPlugin(slug, sentinel, status))
857
+ const group = ours && typeof ours === 'object' ? ours : {};
858
+ const handler = Array.isArray(group.hooks) && group.hooks[0] && typeof group.hooks[0] === 'object'
859
+ ? group.hooks[0]
860
+ : {};
861
+ if (handler.command !== hookCommandForPlugin(slug, sentinel, status)
862
+ || (matcher ?? undefined) !== group.matcher
863
+ || (timeout ?? undefined) !== handler.timeout)
784
864
  complete = false;
785
865
  }
786
866
  else {
@@ -792,7 +872,7 @@ function pluginHooksState(slug, file, events) {
792
872
  return complete ? 'installed' : 'outdated';
793
873
  }
794
874
  /** Generic install for a hookmap-style hooks file. */
795
- function installPluginHooks(slug, file, events) {
875
+ export function installPluginHooks(slug, file, events) {
796
876
  const { devMode } = resolveHookScript();
797
877
  let root = {};
798
878
  try {
@@ -815,7 +895,7 @@ function installPluginHooks(slug, file, events) {
815
895
  }
816
896
  const hooks = root.hooks;
817
897
  const mark = marker(slug);
818
- for (const [hookEvent, sentinel, matcher, , status] of events) {
898
+ for (const [hookEvent, sentinel, matcher, timeout, status] of events) {
819
899
  const command = hookCommandForPlugin(slug, sentinel, status);
820
900
  let list = hooks[hookEvent];
821
901
  if (list === undefined) {
@@ -825,7 +905,10 @@ function installPluginHooks(slug, file, events) {
825
905
  if (!Array.isArray(list))
826
906
  continue;
827
907
  const kept = list.filter((entry) => markerCommand(entry, mark) === null);
828
- const entry = { hooks: [{ type: 'command', command }] };
908
+ const handler = { type: 'command', command };
909
+ if (timeout !== undefined)
910
+ handler.timeout = timeout;
911
+ const entry = { hooks: [handler] };
829
912
  if (matcher)
830
913
  entry.matcher = matcher;
831
914
  kept.push(entry);
@@ -236,6 +236,50 @@ export function validateManifest(raw, dir) {
236
236
  };
237
237
  }
238
238
  }
239
+ // Optional automatic-title provider. Commands are argv arrays rather than
240
+ // shell snippets so a UI-installed manifest cannot gain accidental shell
241
+ // interpolation beyond the executable it explicitly declares.
242
+ let titleNamer;
243
+ if (m.titleNamer !== undefined) {
244
+ if (typeof m.titleNamer !== 'object' || Array.isArray(m.titleNamer) || m.titleNamer === null) {
245
+ errors.push('titleNamer must be an object if present');
246
+ }
247
+ else {
248
+ const n = m.titleNamer;
249
+ const command = n.command;
250
+ const args = n.args;
251
+ if (typeof command !== 'string' || command.trim().length === 0) {
252
+ errors.push('titleNamer.command is required');
253
+ }
254
+ if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string') || !args.some((arg) => arg.includes('{prompt}'))) {
255
+ errors.push('titleNamer.args must be a string array containing {prompt}');
256
+ }
257
+ let models;
258
+ if (n.models !== undefined) {
259
+ if (typeof n.models !== 'object' || Array.isArray(n.models) || n.models === null) {
260
+ errors.push('titleNamer.models must be an object if present');
261
+ }
262
+ else {
263
+ const rawModels = n.models;
264
+ if (typeof rawModels.command !== 'string' || rawModels.command.trim().length === 0) {
265
+ errors.push('titleNamer.models.command is required');
266
+ }
267
+ if (rawModels.args !== undefined && (!Array.isArray(rawModels.args) || !rawModels.args.every((arg) => typeof arg === 'string'))) {
268
+ errors.push('titleNamer.models.args must be a string array if present');
269
+ }
270
+ if (typeof rawModels.command === 'string') {
271
+ models = {
272
+ command: rawModels.command.trim(),
273
+ args: Array.isArray(rawModels.args) ? rawModels.args : undefined,
274
+ };
275
+ }
276
+ }
277
+ }
278
+ if (typeof command === 'string' && Array.isArray(args)) {
279
+ titleNamer = { command: command.trim(), args: args, models };
280
+ }
281
+ }
282
+ }
239
283
  if (errors.length > 0) {
240
284
  return {
241
285
  error: {
@@ -259,6 +303,7 @@ export function validateManifest(raw, dir) {
259
303
  statuses,
260
304
  hooks,
261
305
  resume: resumeConfig,
306
+ titleNamer,
262
307
  },
263
308
  };
264
309
  }
@@ -33,7 +33,7 @@ export function agentBySlug(slug) {
33
33
  return BY_SLUG.get(slug.trim().toLowerCase()) ?? null;
34
34
  }
35
35
  export function listAgents() {
36
- return AGENTS.slice();
36
+ return [...BY_SLUG.values()];
37
37
  }
38
38
  function matchToken(token) {
39
39
  return BY_ALIAS.get(token) ?? null;
@@ -17,6 +17,13 @@ const EVENT_KINDS = new Set([
17
17
  'session-start', 'prompt-submit', 'permission-request', 'question-asked',
18
18
  'tool-complete', 'notification', 'stop', 'session-end',
19
19
  ]);
20
+ /** A hook may brand an unclaimed pane, but it may not overwrite or decorate
21
+ * a pane already owned by a different detected Agent. */
22
+ export function agentEventMatchesCurrentAgent(current, event) {
23
+ if (event.agentSlug && !event.agent)
24
+ return false;
25
+ return !current || !event.agent || current.slug === event.agent.slug;
26
+ }
20
27
  export function defaultAgentSessionState() {
21
28
  return {
22
29
  status: 'idle',
@@ -140,13 +147,15 @@ export function parseAgentEvent(payload) {
140
147
  const eventName = nonEmpty(w.event);
141
148
  if (!eventName || !EVENT_KINDS.has(eventName))
142
149
  return null;
143
- const agent = agentBySlug(nonEmpty(w.agent));
150
+ const agentSlug = nonEmpty(w.agent);
151
+ const agent = agentBySlug(agentSlug);
144
152
  const statusId = nonEmpty(w.status);
145
153
  const status = statusId
146
154
  ? agent?.statuses?.find((definition) => definition.id === statusId) ?? null
147
155
  : null;
148
156
  return {
149
157
  agent,
158
+ agentSlug,
150
159
  kind: eventName,
151
160
  sessionId: nonEmpty(w.session_id),
152
161
  message: nonEmpty(w.message),
@@ -3,12 +3,16 @@ import fs from 'fs';
3
3
  import os from 'os';
4
4
  import path from 'path';
5
5
  import { promisify } from 'util';
6
+ import { loadPlugins } from './plugins.js';
6
7
  const execFileAsync = promisify(execFile);
7
8
  const CACHE_FRESH_MS = 24 * 60 * 60_000;
8
9
  const PROBE_TIMEOUT_MS = 10_000;
9
10
  const CACHE_FILE = path.join(os.homedir(), '.termdock', 'title-namer-catalog.json');
10
11
  let cached = null;
11
12
  let refreshPromise = null;
13
+ export function invalidateTitleNamerCatalog() {
14
+ cached = null;
15
+ }
12
16
  function normalizeCachedCatalog(input) {
13
17
  if (!input || typeof input !== 'object')
14
18
  return null;
@@ -19,7 +23,7 @@ function normalizeCachedCatalog(input) {
19
23
  if (!entry || typeof entry !== 'object')
20
24
  return [];
21
25
  const candidate = entry;
22
- if (candidate.slug !== 'codex' && candidate.slug !== 'claude')
26
+ if (typeof candidate.slug !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(candidate.slug))
23
27
  return [];
24
28
  if (!Array.isArray(candidate.models))
25
29
  return [];
@@ -155,11 +159,89 @@ async function listClaudeModels() {
155
159
  return parseClaudeSupportedModels(`${candidate.stdout ?? ''}\n${candidate.stderr ?? ''}`);
156
160
  }
157
161
  }
162
+ function normalizeDiscoveredModels(input) {
163
+ if (!Array.isArray(input))
164
+ return [];
165
+ return input.flatMap((entry) => {
166
+ if (typeof entry === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,119}$/.test(entry)) {
167
+ return [{ id: entry, displayName: entry, description: '', isDefault: false }];
168
+ }
169
+ if (!entry || typeof entry !== 'object')
170
+ return [];
171
+ const model = entry;
172
+ if (typeof model.id !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,119}$/.test(model.id))
173
+ return [];
174
+ return [{
175
+ id: model.id,
176
+ displayName: typeof model.displayName === 'string' ? model.displayName : model.id,
177
+ description: typeof model.description === 'string' ? model.description : '',
178
+ isDefault: model.isDefault === true,
179
+ }];
180
+ });
181
+ }
182
+ async function listPluginModels(config) {
183
+ if (!config.models)
184
+ return [];
185
+ const { stdout } = await execFileAsync(config.models.command, config.models.args ?? [], {
186
+ timeout: PROBE_TIMEOUT_MS,
187
+ maxBuffer: 256 * 1024,
188
+ env: { ...process.env, NO_COLOR: '1', TERM: 'dumb' },
189
+ });
190
+ return normalizeDiscoveredModels(JSON.parse(stdout));
191
+ }
192
+ function pluginTitleNamers() {
193
+ return loadPlugins().plugins.flatMap((plugin) => plugin.manifest.titleNamer
194
+ ? [{
195
+ slug: plugin.manifest.slug,
196
+ displayName: plugin.manifest.displayName,
197
+ config: plugin.manifest.titleNamer,
198
+ }]
199
+ : []);
200
+ }
201
+ export async function runTitleNamer(slug, prompt, model) {
202
+ let command;
203
+ let args;
204
+ if (slug === 'codex') {
205
+ command = 'codex';
206
+ args = ['exec', '--ephemeral', '--skip-git-repo-check', '--color', 'never', ...(model ? ['--model', model] : []), prompt];
207
+ }
208
+ else if (slug === 'claude') {
209
+ command = 'claude';
210
+ args = [...(model ? ['--model', model] : []), '--no-session-persistence', '-p', prompt];
211
+ }
212
+ else {
213
+ const provider = pluginTitleNamers().find((entry) => entry.slug === slug);
214
+ if (!provider)
215
+ return null;
216
+ command = provider.config.command;
217
+ args = provider.config.args.flatMap((arg) => {
218
+ if (arg.includes('{model}') && !model)
219
+ return [];
220
+ return [arg.replaceAll('{prompt}', prompt).replaceAll('{model}', model ?? '')];
221
+ });
222
+ }
223
+ try {
224
+ const { stdout } = await execFileAsync(command, args, {
225
+ timeout: 45_000,
226
+ maxBuffer: 256 * 1024,
227
+ env: { ...process.env, NO_COLOR: '1', TERM: 'dumb' },
228
+ });
229
+ return stdout;
230
+ }
231
+ catch {
232
+ return null;
233
+ }
234
+ }
158
235
  async function refreshTitleNamerCatalog() {
159
236
  if (refreshPromise)
160
237
  return refreshPromise;
161
238
  refreshPromise = (async () => {
162
- const [codex, claude] = await Promise.allSettled([listCodexModels(), listClaudeModels()]);
239
+ const plugins = pluginTitleNamers();
240
+ const [codex, claude, ...pluginResults] = await Promise.allSettled([
241
+ listCodexModels(),
242
+ listClaudeModels(),
243
+ ...plugins.map((provider) => listPluginModels(provider.config)),
244
+ ]);
163
245
  const previousCodex = cached?.value.find((namer) => namer.slug === 'codex');
164
246
  const previousClaude = cached?.value.find((namer) => namer.slug === 'claude');
165
247
  const codexModels = codex.status === 'fulfilled' ? codex.value : previousCodex?.models ?? [];
@@ -179,6 +261,18 @@ async function refreshTitleNamerCatalog() {
179
261
  models: claudeModels,
180
262
  recommendedModel: recommendTitleModel(claudeModels),
181
263
  },
264
+ ...plugins.map((provider, index) => {
265
+ const result = pluginResults[index];
266
+ const previous = cached?.value.find((namer) => namer.slug === provider.slug);
267
+ const models = result?.status === 'fulfilled' ? result.value : previous?.models ?? [];
268
+ return {
269
+ slug: provider.slug,
270
+ displayName: provider.displayName,
271
+ available: provider.config.models ? result?.status === 'fulfilled' || previous?.available === true : true,
272
+ models,
273
+ recommendedModel: recommendTitleModel(models),
274
+ };
275
+ }),
182
276
  ];
183
277
  cached = { version: 1, updatedAt: Date.now(), value };
184
278
  await savePersistentCache(cached).catch(() => undefined);
@@ -195,6 +289,10 @@ export async function getTitleNamerCatalog(force = false) {
195
289
  cached = await loadPersistentCache();
196
290
  if (!cached)
197
291
  return refreshTitleNamerCatalog();
292
+ const expectedSlugs = new Set(['codex', 'claude', ...pluginTitleNamers().map((provider) => provider.slug)]);
293
+ if (cached.value.length !== expectedSlugs.size || cached.value.some((entry) => !expectedSlugs.has(entry.slug))) {
294
+ return refreshTitleNamerCatalog();
295
+ }
198
296
  if (Date.now() - cached.updatedAt > CACHE_FRESH_MS) {
199
297
  // Stale-while-revalidate: callers get the last known catalog immediately.
200
298
  void refreshTitleNamerCatalog();
@@ -103,6 +103,47 @@ function ancestorTtyDevice() {
103
103
  }
104
104
  return null;
105
105
  }
106
+ /**
107
+ * tmux passthrough is version/configuration sensitive and, more importantly,
108
+ * only works while tmux considers the escape sequence pane output. Termdock
109
+ * itself is an attached tmux client, so write the sentinel to that client's
110
+ * tty directly when possible. This also works for detached hook subprocesses
111
+ * whose own `/dev/tty` is unavailable.
112
+ */
113
+ function tmuxClientTtyDevices() {
114
+ const pane = process.env.TMUX_PANE;
115
+ if (!process.env.TMUX || !pane)
116
+ return [];
117
+ try {
118
+ const output = execFileSync('tmux', ['list-clients', '-t', pane, '-F', '#{client_tty}\t#{client_pid}'], {
119
+ encoding: 'utf8',
120
+ stdio: ['ignore', 'pipe', 'ignore'],
121
+ timeout: 2000,
122
+ });
123
+ return [...new Set(output.split(/\r?\n/).flatMap((line) => {
124
+ const [device, pid] = line.trim().split('\t');
125
+ if (!/^\/dev\/(?:pts\/\d+|tty[^/]*)$/.test(device ?? ''))
126
+ return [];
127
+ // On Linux the environment gives us an exact ownership marker. An
128
+ // ordinary `tmux attach` must not steal a Termdock sentinel merely
129
+ // because tmux listed that client first.
130
+ const environ = pid && /^\d+$/.test(pid) ? `/proc/${pid}/environ` : null;
131
+ if (environ && fs.existsSync(environ)) {
132
+ try {
133
+ if (!fs.readFileSync(environ).toString().split('\0').includes('TERMDOCK=1'))
134
+ return [];
135
+ }
136
+ catch {
137
+ return [];
138
+ }
139
+ }
140
+ return [device];
141
+ }))];
142
+ }
143
+ catch {
144
+ return [];
145
+ }
146
+ }
106
147
  /**
107
148
  * Write the sequence to the pane's PTY so the server sniffs it as pane output.
108
149
  * Two routes: /dev/tty (hook attached to its tty), or an ancestor's tty
@@ -116,6 +157,12 @@ function ancestorTtyDevice() {
116
157
  * the termdock server — as the plain inner OSC.
117
158
  */
118
159
  function writeToControllingTty(sequence, inTmuxPane) {
160
+ if (inTmuxPane) {
161
+ const clientTtys = tmuxClientTtyDevices();
162
+ if (clientTtys.length > 0) {
163
+ return clientTtys.some((device) => writeDev(device, sequence));
164
+ }
165
+ }
119
166
  // Only genuine tmux panes (TMUX_PANE is set by tmux itself) get the DCS
120
167
  // passthrough wrap; a bare inherited TMUX just means an ancestor ran tmux.
121
168
  const wire = inTmuxPane
@@ -1725,6 +1725,14 @@ const AGENT_PLUGIN_EXAMPLE = {
1725
1725
  command: 'my-agent --resume {sessionId}',
1726
1726
  staleFlags: ['--resume'],
1727
1727
  },
1728
+ titleNamer: {
1729
+ command: 'my-agent',
1730
+ args: ['title', '--json', '--prompt', '{prompt}', '--model={model}'],
1731
+ models: {
1732
+ command: 'my-agent',
1733
+ args: ['models', '--json'],
1734
+ },
1735
+ },
1728
1736
  };
1729
1737
  function runPluginInitJson() {
1730
1738
  console.log(JSON.stringify({
@@ -1770,6 +1778,8 @@ function runPluginInitJson() {
1770
1778
  'Run td plugin-create <manifest-file>; the Agent appears in Termdock immediately.',
1771
1779
  'Ask the user to open Settings > Detection Rules and click Install for this Agent. They can uninstall it there later.',
1772
1780
  'If the Agent hook format is not a Claude-style hooks map, configure native hooks to call td agent-event directly.',
1781
+ 'Map the completed-turn hook to event "stop"; this is also the automatic-title trigger when the user enables titles for the Agent.',
1782
+ 'Optionally declare titleNamer so this Agent can generate titles and dynamically publish its own model catalog.',
1773
1783
  'Run td plugin-list to verify registration.',
1774
1784
  ],
1775
1785
  manifest: AGENT_PLUGIN_EXAMPLE,
@@ -1874,6 +1884,15 @@ ${c.dim('───────────────────')}
1874
1884
  "command": "trae --resume {sessionId}",
1875
1885
  ${c.dim('// Flags to strip from the original launch argv when replaying the resume.')}
1876
1886
  "staleFlags": ["--resume", "-r", "--session-id", "--continue"]
1887
+ },
1888
+
1889
+ ${c.dim('// ── optional: injectable automatic-title provider ──')}
1890
+ "titleNamer": {
1891
+ ${c.dim('// No shell expansion. {prompt} is required; a bare {model} arg is omitted when automatic.')}
1892
+ "command": "trae",
1893
+ "args": ["title", "--prompt", "{prompt}", "--model={model}"],
1894
+ ${c.dim('// Must print JSON: strings or {id, displayName, description, isDefault} objects.')}
1895
+ "models": { "command": "trae", "args": ["models", "--json"] }
1877
1896
  }
1878
1897
  }
1879
1898
 
@@ -1887,6 +1906,7 @@ ${c.dim('─────────────────────')}
1887
1906
  tool-complete A tool execution completed (unblocks waiting state)
1888
1907
  notification Generic notification from the agent (heuristic gated)
1889
1908
  stop Agent turn finished / went idle
1909
+ Also triggers automatic title generation when enabled
1890
1910
  session-end Agent session closed / binary exited
1891
1911
 
1892
1912
  ${c.bold('Creating a plugin')}