termdock 1.4.69 → 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 +6 -0
  3. package/dist/client/assets/{ModelPreview-Bk8GM7G-.js → ModelPreview-Cs14IQuQ.js} +1 -1
  4. package/dist/client/assets/{abnfDiagram-VRR7QNED-BYiQXGYk.js → abnfDiagram-VRR7QNED-DuDfdv-6.js} +1 -1
  5. package/dist/client/assets/{arc-jlT_jmoZ.js → arc-CSYzGV_2.js} +1 -1
  6. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-D1qZ875Q.js → architectureDiagram-ZJ3FMSHR-YiZ0HXNi.js} +1 -1
  7. package/dist/client/assets/{blockDiagram-677ZJIJ3-qkgn2Ysc.js → blockDiagram-677ZJIJ3-IaQMzmYv.js} +1 -1
  8. package/dist/client/assets/{c4Diagram-LMCZKHZV-JgcH49Pf.js → c4Diagram-LMCZKHZV-BwyuIPRL.js} +1 -1
  9. package/dist/client/assets/channel-BotxtiNE.js +1 -0
  10. package/dist/client/assets/{chunk-2Q5K7J3B-E-3iflEy.js → chunk-2Q5K7J3B-BtHQYLBT.js} +1 -1
  11. package/dist/client/assets/{chunk-32BRIVSS-mM01y3kF.js → chunk-32BRIVSS-CXrK07sW.js} +1 -1
  12. package/dist/client/assets/{chunk-5VM5RSS4-GO8Muo5l.js → chunk-5VM5RSS4-BfBshn11.js} +1 -1
  13. package/dist/client/assets/{chunk-EX3LRPZG-BUSDluH0.js → chunk-EX3LRPZG-BJbilNaf.js} +1 -1
  14. package/dist/client/assets/{chunk-JWPE2WC7-obSL6EzJ.js → chunk-JWPE2WC7-wgcmSRlL.js} +1 -1
  15. package/dist/client/assets/{chunk-MOJQB5TN-zEktbtAG.js → chunk-MOJQB5TN-8VZVUeDI.js} +1 -1
  16. package/dist/client/assets/{chunk-RYQCIY6F-kH7H8XgQ.js → chunk-RYQCIY6F-B900YBFa.js} +1 -1
  17. package/dist/client/assets/{chunk-V7JOEXUC-BqDFpHKN.js → chunk-V7JOEXUC-BK4_RLz-.js} +1 -1
  18. package/dist/client/assets/{chunk-VR4S4FIN-BGZ7kCIG.js → chunk-VR4S4FIN-Kj5_ycjB.js} +1 -1
  19. package/dist/client/assets/{chunk-XXDRQBXY-D-TatWXe.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-CfWyrViz.js → cose-bilkent-JH36ORCC-9_9_SLqv.js} +1 -1
  23. package/dist/client/assets/{cynefin-VYW2F7L2-BFdZ3FcE.js → cynefin-VYW2F7L2-yEpoi3vG.js} +1 -1
  24. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-iAfCVoFU.js → cynefinDiagram-TSTJHNR4-Dy-gvup_.js} +1 -1
  25. package/dist/client/assets/{dagre-VKFMJZFB-CZhh0hrr.js → dagre-VKFMJZFB-CNjJ5pRK.js} +1 -1
  26. package/dist/client/assets/{diagram-FQU43EPY-BqUK2OmV.js → diagram-FQU43EPY-uZ73Rj3T.js} +1 -1
  27. package/dist/client/assets/{diagram-G47NLZAW-vC0pSQYC.js → diagram-G47NLZAW-Cxt7TYZT.js} +1 -1
  28. package/dist/client/assets/{diagram-NH7WQ7WH-Dqc5oxed.js → diagram-NH7WQ7WH-B4TKinuZ.js} +1 -1
  29. package/dist/client/assets/{diagram-OA4YK3LP-DIIXckFE.js → diagram-OA4YK3LP-CGSuenHU.js} +1 -1
  30. package/dist/client/assets/{diagram-WEI45ONY-DR9_cFeh.js → diagram-WEI45ONY-DqqzDaUZ.js} +1 -1
  31. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-21OO0OXC.js → ebnfDiagram-CCIWWBDH-B2SgXL5w.js} +1 -1
  32. package/dist/client/assets/{erDiagram-Q63AITRT-YKnVG3az.js → erDiagram-Q63AITRT-D-ibkr8y.js} +1 -1
  33. package/dist/client/assets/{flowDiagram-23GEKE2U-Cm7TGErF.js → flowDiagram-23GEKE2U-BPeyMqr-.js} +1 -1
  34. package/dist/client/assets/{ganttDiagram-NO4QXBWP-D_76Effz.js → ganttDiagram-NO4QXBWP-DcEqEzzu.js} +1 -1
  35. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-CC1ysmVD.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-BbgpmhBz.js → infoDiagram-FWYZ7A6U-BHrzVXeX.js} +1 -1
  39. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-C94bY_je.js → ishikawaDiagram-FXEZZL3T-CwuoQJku.js} +1 -1
  40. package/dist/client/assets/{journeyDiagram-5HDEW3XC-BQ0iFRxs.js → journeyDiagram-5HDEW3XC-ZEcy0rDZ.js} +1 -1
  41. package/dist/client/assets/{kanban-definition-HUTT4EX6-BtAexXPo.js → kanban-definition-HUTT4EX6-BDBawNbr.js} +1 -1
  42. package/dist/client/assets/{linear-C2HnIdOK.js → linear-smYQWzDV.js} +1 -1
  43. package/dist/client/assets/{mermaid.core-BbG4sJBF.js → mermaid.core-eJs7Ouvw.js} +4 -4
  44. package/dist/client/assets/{mindmap-definition-LN4V7U3C-B-uvViws.js → mindmap-definition-LN4V7U3C-BK1IZ9zg.js} +1 -1
  45. package/dist/client/assets/{pegDiagram-2B236MQR-DHYcR6tE.js → pegDiagram-2B236MQR-B3-mVNgX.js} +1 -1
  46. package/dist/client/assets/{pieDiagram-ENE6RG2P-DEE9cD6M.js → pieDiagram-ENE6RG2P-Bkeeyx5l.js} +1 -1
  47. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-DMQP5jG3.js → quadrantDiagram-ABIIQ3AL-DouIklEO.js} +1 -1
  48. package/dist/client/assets/{railroadDiagram-RFXS5EU6-B-4WDTwc.js → railroadDiagram-RFXS5EU6-BB_jXQd2.js} +1 -1
  49. package/dist/client/assets/{requirementDiagram-TGXJPOKE-C2xw_q-R.js → requirementDiagram-TGXJPOKE-u56IHfiR.js} +1 -1
  50. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-wq0BPqhc.js → sankeyDiagram-HTMAVEWB-eDi2cs5E.js} +1 -1
  51. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-BqpM0A9d.js → sequenceDiagram-DBY2YBRQ-DnAA2xYa.js} +1 -1
  52. package/dist/client/assets/{sizeCapture-X5ZJPWSS-Ba2xxyp_.js → sizeCapture-X5ZJPWSS-BogPui79.js} +1 -1
  53. package/dist/client/assets/{stateDiagram-2N3HPSRC-agPx5qAN.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-Cy6Lzj_L.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-LBrVfbdp.js → timeline-definition-FHXFAJF6-DkcnpB-Y.js} +1 -1
  58. package/dist/client/assets/{vennDiagram-L72KCM5P-BiYIV7mX.js → vennDiagram-L72KCM5P-BStcDGEf.js} +1 -1
  59. package/dist/client/assets/{wardleyDiagram-EHGQE667-Q1QU9gG3.js → wardleyDiagram-EHGQE667-Cj4CQn1M.js} +1 -1
  60. package/dist/client/assets/{xychartDiagram-FW5EYKEG-CzYpxk4l.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 +218 -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 +2 -1
  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-C2i7H7bH.js +0 -1
  79. package/dist/client/assets/classDiagram-OUVF2IWQ-BlGG6XFj.js +0 -1
  80. package/dist/client/assets/classDiagram-v2-EOCWNBFH-BlGG6XFj.js +0 -1
  81. package/dist/client/assets/index-ChhyERKX.css +0 -1
  82. package/dist/client/assets/index-r-PkH7SB.js +0 -442
  83. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-beoQXpWJ.js +0 -1
  84. package/dist/client/assets/swimlanesDiagram-G3AALYLV-BxL1xNhC.js +0 -8
@@ -6,7 +6,7 @@ import { createRequire } from 'module';
6
6
  import { fileURLToPath } from 'url';
7
7
  import QRCode from 'qrcode';
8
8
  import { execFile, spawn } from 'child_process';
9
- import { randomUUID } from 'crypto';
9
+ import { createHash, randomUUID } from 'crypto';
10
10
  import { promisify } from 'util';
11
11
  import { caffeinateManager } from '../utils/caffeinate.js';
12
12
  import { gitStatusCache } from '../utils/gitStatus.js';
@@ -14,19 +14,22 @@ import { getPtyHostManager } from '../ptyhost/manager.js';
14
14
  import { pathValidator } from '../utils/pathValidator.js';
15
15
  import { TERMINAL, TMUX } from '../config.js';
16
16
  import { localAccessManager } from '../utils/localAccess.js';
17
- import { normalizeLocalAccessName, getLocaleSetting, setLocaleSetting, getContextDraftHeightSetting, setContextDraftHeightSetting, } from '../utils/settings.js';
17
+ import { normalizeLocalAccessName, getLocaleSetting, setLocaleSetting, getContextDraftHeightSetting, setContextDraftHeightSetting, getAutoRenameAgentsSetting, setAutoRenameAgentsSetting, getAutoRenameNamerSetting, setAutoRenameNamerSetting, getAutoRenameModelsSetting, setAutoRenameModelsSetting, } from '../utils/settings.js';
18
18
  import { loadContextDraft, saveContextDraft } from '../utils/contextDraft.js';
19
19
  import { getOnboardingServerUrl } from '../onboardingServer.js';
20
20
  import { getFocusSequence, removeClientFocusState, scanFocusTrackingMode, setClientFocusState, } from '../utils/tmuxFocus.js';
21
21
  import { extractProgramLabelFromArgs, normalizeProgramName, normalizeTmuxMetadataProgram, selectTmuxForegroundProgram, tmuxMetadataChanged, } from '../utils/tmuxProgramDetection.js';
22
- import { agentBySlug, buildResumeCommand, detectAgentFromCommand, } from '../agent/registry.js';
22
+ import { agentBySlug, buildResumeCommand, detectAgentFromCommand, listAgents, } from '../agent/registry.js';
23
23
  import { applyAgentEvent, defaultAgentSessionState, parseAgentEvent, } from '../agent/session.js';
24
24
  import { listAllHookAgents, refreshStaleHooksAtLaunch, installHooksForSlug, uninstallHooksForSlug, } from '../agent/installers.js';
25
- import { loadPlugins, savePlugin, removePlugin, readPluginIcon, } from '../agent/plugins.js';
26
- import { registerPluginAgents } from '../agent/registry.js';
25
+ import { loadPlugins, savePlugin, removePlugin, readPluginIcon, validateManifest, } from '../agent/plugins.js';
26
+ import { clearPluginAgents, registerPluginAgents } from '../agent/registry.js';
27
27
  import { notifyAgentTransition, notifyTerminalExit } from '../notifications/pushService.js';
28
28
  import { setClientViewingSession } from '../notifications/pushViewers.js';
29
29
  import { getQuotaStatus, refreshQuota, startQuotaManager, } from '../quota/QuotaManager.js';
30
+ import { TermdockAutoUpdateManager } from '../utils/autoUpdate.js';
31
+ import { cleanTerminalContext, generateAgentTitle, isNewAgentSessionId, shouldReplaceAutoTitle } from '../agent/autoTitle.js';
32
+ import { getTitleNamerCatalog } from '../agent/titleNamerCatalog.js';
30
33
  const router = express.Router();
31
34
  const execFileAsync = promisify(execFile);
32
35
  const TERMDOCK_DIR = `${os.homedir()}/.termdock`;
@@ -193,6 +196,67 @@ function sendClientStatePayload(payload) {
193
196
  function broadcastControlEvent(payload) {
194
197
  sendClientStatePayload(JSON.stringify(payload));
195
198
  }
199
+ let serverRestartScheduled = false;
200
+ function requestServerRestartAfterUpdate() {
201
+ if (serverRestartScheduled)
202
+ return;
203
+ serverRestartScheduled = true;
204
+ // The bridge is detached before this process exits. It waits for the old
205
+ // PID to release the port, then resolves `termdock` from PATH again so an
206
+ // npm-replaced global binary is used instead of the old loaded file.
207
+ const bridgeSource = String.raw `
208
+ const fs = require('node:fs');
209
+ const { spawn } = require('node:child_process');
210
+ const payload = JSON.parse(process.argv[1]);
211
+ const alive = () => { try { process.kill(payload.parentPid, 0); return true; } catch { return false; } };
212
+ const launch = () => {
213
+ let fd;
214
+ try { fd = fs.openSync(payload.logFile, 'a'); } catch { fd = 'ignore'; }
215
+ const command = process.platform === 'win32' ? 'termdock.cmd' : 'termdock';
216
+ const child = spawn(command, payload.args, {
217
+ detached: true,
218
+ stdio: ['ignore', fd, fd],
219
+ env: process.env,
220
+ });
221
+ child.unref();
222
+ if (typeof fd === 'number') fs.closeSync(fd);
223
+ };
224
+ const deadline = Date.now() + 15000;
225
+ const timer = setInterval(() => {
226
+ if (alive() && Date.now() < deadline) return;
227
+ clearInterval(timer);
228
+ setTimeout(launch, 250);
229
+ }, 100);
230
+ `;
231
+ const payload = JSON.stringify({
232
+ parentPid: process.pid,
233
+ args: process.argv.slice(2),
234
+ logFile: path.join(TERMDOCK_DIR, 'server.log'),
235
+ });
236
+ const bridge = spawn(process.execPath, ['-e', bridgeSource, payload], {
237
+ detached: true,
238
+ stdio: 'ignore',
239
+ env: process.env,
240
+ });
241
+ bridge.unref();
242
+ const exitTimer = setTimeout(() => {
243
+ process.kill(process.pid, 'SIGTERM');
244
+ }, 750);
245
+ exitTimer.unref?.();
246
+ }
247
+ const npmAutoUpdateManager = new TermdockAutoUpdateManager({
248
+ currentVersion: TERMDOCK_VERSION,
249
+ stateFilePath: path.join(TERMDOCK_DIR, 'update-state.json'),
250
+ broadcast: (state) => broadcastControlEvent({ type: 'update-state', state }),
251
+ requestRestart: requestServerRestartAfterUpdate,
252
+ log: (message) => console.warn(message),
253
+ });
254
+ // Packaged desktop builds already have a signed app/runtime updater. The npm
255
+ // updater only owns standalone CLI services, otherwise both mechanisms could
256
+ // race to replace the runtime.
257
+ if (process.env.TERMDOCK_DESKTOP !== '1') {
258
+ npmAutoUpdateManager.start();
259
+ }
196
260
  async function flushClientStateBroadcast() {
197
261
  if (broadcastClientStateInFlight) {
198
262
  broadcastClientStateNeedsRerun = true;
@@ -643,6 +707,12 @@ function normalizePersistedClientSession(input) {
643
707
  sessionId: candidate.sessionId,
644
708
  name: candidate.name,
645
709
  customName: candidate.customName === true ? true : undefined,
710
+ autoTitle: candidate.autoTitle
711
+ && typeof candidate.autoTitle.agentSlug === 'string'
712
+ && typeof candidate.autoTitle.contentHash === 'string'
713
+ && typeof candidate.autoTitle.updatedAt === 'number'
714
+ ? candidate.autoTitle
715
+ : null,
646
716
  backendSessionId: typeof candidate.backendSessionId === 'string' && candidate.backendSessionId.trim().length > 0
647
717
  ? candidate.backendSessionId
648
718
  : null,
@@ -1301,7 +1371,10 @@ async function configureTmuxWheelBindings() {
1301
1371
  ]);
1302
1372
  await runTmux([
1303
1373
  'bind-key', '-n', 'MouseDrag1Pane',
1304
- "if-shell -F '#{||:#{pane_in_mode},#{mouse_any_flag}}' 'send-keys -M' 'if-shell -F \"#{||:#{e|>=:#{e|-:#{mouse_x},#{@termdock-mouse-down-x}},1},#{e|>=:#{e|-:#{@termdock-mouse-down-x},#{mouse_x}},1},#{e|>=:#{e|-:#{mouse_y},#{@termdock-mouse-down-y}},1},#{e|>=:#{e|-:#{@termdock-mouse-down-y},#{mouse_y}},1}}\" \"copy-mode -M\"'",
1374
+ // xterm reports button-motion in character cells. A one-cell movement is
1375
+ // common mouse jitter during an ordinary click, so require two cells before
1376
+ // treating the gesture as an intentional text-selection drag.
1377
+ "if-shell -F '#{||:#{pane_in_mode},#{mouse_any_flag}}' 'send-keys -M' 'if-shell -F \"#{||:#{||:#{e|>=:#{e|-:#{mouse_x},#{@termdock-mouse-down-x}},2},#{e|>=:#{e|-:#{@termdock-mouse-down-x},#{mouse_x}},2}},#{||:#{e|>=:#{e|-:#{mouse_y},#{@termdock-mouse-down-y}},2},#{e|>=:#{e|-:#{@termdock-mouse-down-y},#{mouse_y}},2}}}\" \"copy-mode -M\"'",
1305
1378
  ]);
1306
1379
  await runTmux([
1307
1380
  'bind-key', '-n', 'DoubleClick1Pane',
@@ -2054,6 +2127,98 @@ function detectSessionAgent(session) {
2054
2127
  }
2055
2128
  return null;
2056
2129
  }
2130
+ const AUTO_TITLE_CONTEXT_LIMIT = 24_000;
2131
+ const AUTO_TITLE_REEVALUATE_MS = 60 * 60_000;
2132
+ const autoTitlePending = new Set();
2133
+ function appendAutoTitleContext(session, data) {
2134
+ session.autoTitleContext = (session.autoTitleContext + data).slice(-AUTO_TITLE_CONTEXT_LIMIT);
2135
+ }
2136
+ function clearAutoTitleForNewAgentSession(sessionId, session) {
2137
+ const record = globalSessionState.sessions.find((item) => item.backendSessionId === sessionId);
2138
+ if (!record?.autoTitle)
2139
+ return;
2140
+ const next = { ...record, customName: undefined, autoTitle: null };
2141
+ upsertGlobalSessionRecord(next);
2142
+ session.autoTitleContext = '';
2143
+ if (next.mode === 'tmux' && next.tmuxSessionName) {
2144
+ void unsetTmuxOption(next.tmuxSessionName, '@termdock-friendly-name');
2145
+ }
2146
+ schedulePersistGlobalState();
2147
+ broadcastClientState();
2148
+ console.log(`[auto-title] cleared stale title for new agent session in ${sessionId}`);
2149
+ }
2150
+ async function maybeAutoRenameSession(sessionId, agent, rawContext) {
2151
+ if (autoTitlePending.has(sessionId))
2152
+ return;
2153
+ if (!getAutoRenameAgentsSetting().includes(agent.slug))
2154
+ return;
2155
+ const record = globalSessionState.sessions.find((item) => item.backendSessionId === sessionId);
2156
+ if (!record || (record.customName === true && !record.autoTitle))
2157
+ return;
2158
+ if (record.autoTitle && Date.now() - record.autoTitle.updatedAt < AUTO_TITLE_REEVALUATE_MS)
2159
+ return;
2160
+ const context = cleanTerminalContext(rawContext);
2161
+ if (context.length < 120)
2162
+ return;
2163
+ const contentHash = createHash('sha256').update(context).digest('hex');
2164
+ if (record.autoTitle?.contentHash === contentHash)
2165
+ return;
2166
+ autoTitlePending.add(sessionId);
2167
+ try {
2168
+ const savedModels = getAutoRenameModelsSetting();
2169
+ const catalog = await getTitleNamerCatalog();
2170
+ const availableModels = new Map(catalog.map((namer) => [
2171
+ namer.slug,
2172
+ new Set(namer.models.map((model) => model.id)),
2173
+ ]));
2174
+ const validModels = Object.fromEntries(Object.entries(savedModels)
2175
+ .filter(([slug, model]) => availableModels.get(slug)?.has(model)));
2176
+ for (const namer of catalog) {
2177
+ if (!validModels[namer.slug] && namer.recommendedModel) {
2178
+ validModels[namer.slug] = namer.recommendedModel;
2179
+ }
2180
+ }
2181
+ const title = await generateAgentTitle(agent.slug, agent.displayName, context, {
2182
+ namer: getAutoRenameNamerSetting(),
2183
+ models: validModels,
2184
+ currentTitle: record.autoTitle ? record.name : undefined,
2185
+ });
2186
+ if (!title)
2187
+ return;
2188
+ // A manual rename performed while the namer was running always wins.
2189
+ const current = globalSessionState.sessions.find((item) => item.backendSessionId === sessionId);
2190
+ if (!current || (current.customName === true && !current.autoTitle))
2191
+ return;
2192
+ if (current.autoTitle && !shouldReplaceAutoTitle(current.name, title)) {
2193
+ upsertGlobalSessionRecord({
2194
+ ...current,
2195
+ autoTitle: { agentSlug: agent.slug, contentHash, updatedAt: Date.now() },
2196
+ });
2197
+ await persistGlobalStateNow();
2198
+ console.log(`[auto-title] kept ${sessionId} title ${JSON.stringify(current.name)}`);
2199
+ return;
2200
+ }
2201
+ const next = {
2202
+ ...current,
2203
+ name: title,
2204
+ customName: true,
2205
+ autoTitle: { agentSlug: agent.slug, contentHash, updatedAt: Date.now() },
2206
+ };
2207
+ if (next.mode === 'tmux' && next.tmuxSessionName) {
2208
+ await setTmuxOption(next.tmuxSessionName, '@termdock-friendly-name', title);
2209
+ }
2210
+ upsertGlobalSessionRecord(next);
2211
+ await persistGlobalStateNow();
2212
+ broadcastClientState();
2213
+ console.log(`[auto-title] renamed ${sessionId} (${agent.slug}) to ${JSON.stringify(title)}`);
2214
+ }
2215
+ catch (error) {
2216
+ console.warn(`[auto-title] failed for ${sessionId}: ${getErrorMessage(error)}`);
2217
+ }
2218
+ finally {
2219
+ autoTitlePending.delete(sessionId);
2220
+ }
2221
+ }
2057
2222
  /** Status → indicator glyph. Colors stay client-side (CSS theme vars) so
2058
2223
  * dark/light themes render correctly; the wire carries semantics only. */
2059
2224
  const AGENT_STATUS_INDICATOR = {
@@ -2074,7 +2239,8 @@ function isPaneAtShellPrompt(session) {
2074
2239
  function buildAgentStatusPayload(sessionId, session) {
2075
2240
  const state = session.agentSession;
2076
2241
  const status = state ? state.status : null;
2077
- const indicator = status ? AGENT_STATUS_INDICATOR[status] : null;
2242
+ const presentation = state?.presentation ?? null;
2243
+ const indicator = presentation?.indicator ?? (status ? AGENT_STATUS_INDICATOR[status] : null);
2078
2244
  let agent = session.agent;
2079
2245
  let nativeSessionId = state?.sessionId ?? null;
2080
2246
  // The agent just exited: keep offering its last conversation for resume
@@ -2094,6 +2260,9 @@ function buildAgentStatusPayload(sessionId, session) {
2094
2260
  type: 'agent-status',
2095
2261
  agentStatus: status,
2096
2262
  agentIndicator: indicator ?? null,
2263
+ agentStatusDetail: presentation
2264
+ ? { id: presentation.id, label: presentation.label, tone: presentation.tone ?? 'neutral' }
2265
+ : null,
2097
2266
  agent: agent
2098
2267
  ? { slug: agent.slug, displayName: agent.displayName, accentColor: agent.accentColor, icon: agent.icon, isPlugin: agent.isPlugin ?? false, iconMode: agent.iconMode, iconVersion: agent.iconVersion }
2099
2268
  : null,
@@ -2218,6 +2387,7 @@ function applyAgentSignals(sessionId, session, events, notification) {
2218
2387
  // that a poll blip may have started in syncAgentIdentity.
2219
2388
  session.agentLeftAt = null;
2220
2389
  let identityChanged = false;
2390
+ let completedTurnAgent = null;
2221
2391
  for (const event of events) {
2222
2392
  // An event naming an agent brands the pane even when the process poll
2223
2393
  // can't see through a wrapper: identity via protocol.
@@ -2227,7 +2397,25 @@ function applyAgentSignals(sessionId, session, events, notification) {
2227
2397
  }
2228
2398
  const state = session.agentSession ?? defaultAgentSessionState();
2229
2399
  session.agentSession = state;
2400
+ const previousNativeSessionId = state.sessionId
2401
+ ?? globalSessionState.sessions.find((item) => item.backendSessionId === sessionId)?.agentResume?.sessionId
2402
+ ?? null;
2403
+ if (event.kind === 'session-start' && isNewAgentSessionId(previousNativeSessionId, event.sessionId)) {
2404
+ clearAutoTitleForNewAgentSession(sessionId, session);
2405
+ }
2406
+ if (event.kind === 'prompt-submit') {
2407
+ // Before the first automatic title, keep accumulating short turns until
2408
+ // there is enough substance to name the session. Once titled, only the
2409
+ // latest turn is relevant to the conservative re-evaluation path.
2410
+ const persisted = globalSessionState.sessions.find((item) => item.backendSessionId === sessionId);
2411
+ if (persisted?.autoTitle) {
2412
+ session.autoTitleContext = '';
2413
+ }
2414
+ }
2230
2415
  applyAgentEvent(state, event);
2416
+ if (event.kind === 'stop') {
2417
+ completedTurnAgent = event.agent ?? session.agent;
2418
+ }
2231
2419
  if (event.kind === 'session-start' || event.kind === 'session-end' || event.kind === 'stop') {
2232
2420
  persistAgentResumeBinding(sessionId, session);
2233
2421
  }
@@ -2259,6 +2447,13 @@ function applyAgentSignals(sessionId, session, events, notification) {
2259
2447
  // one of the git probe's triggers.
2260
2448
  maybeRefreshGitStatusForAgent(sessionId, session);
2261
2449
  broadcastAgentStatus(sessionId, session, identityChanged);
2450
+ if (completedTurnAgent) {
2451
+ const agent = completedTurnAgent;
2452
+ // Let the current PTY chunk reach the rolling context before reading it.
2453
+ setTimeout(() => {
2454
+ void maybeAutoRenameSession(sessionId, agent, session.autoTitleContext);
2455
+ }, 250).unref?.();
2456
+ }
2262
2457
  }
2263
2458
  // ── Git status (branch + diff size) ──
2264
2459
  //
@@ -2407,6 +2602,9 @@ try {
2407
2602
  }
2408
2603
  for (const err of errors) {
2409
2604
  console.warn(`[agent-plugins] validation error in "${err.slug}": ${err.errors.join('; ')}`);
2605
+ if (err.migration?.aiPrompt) {
2606
+ console.warn(`[agent-plugins] AI migration prompt for "${err.slug}": ${err.migration.aiPrompt}`);
2607
+ }
2410
2608
  }
2411
2609
  }
2412
2610
  catch { /* plugin loading must never block startup */ }
@@ -3376,6 +3574,7 @@ function processSessionOutput(sessionId, session, data, opts) {
3376
3574
  }
3377
3575
  catch { /* sniff failure should never block data */ }
3378
3576
  if (opts.broadcast) {
3577
+ appendAutoTitleContext(session, data);
3379
3578
  broadcastEvent(sessionId, seq !== undefined ? { type: 'data', data, seq } : { type: 'data', data });
3380
3579
  }
3381
3580
  }
@@ -3546,6 +3745,7 @@ async function spawnTerminalSession(req, input) {
3546
3745
  activeProgram: null,
3547
3746
  agent: null,
3548
3747
  agentSession: null,
3748
+ autoTitleContext: '',
3549
3749
  lastAgentReviewed: null,
3550
3750
  agentLeftAt: null,
3551
3751
  gitStatus: null,
@@ -3627,6 +3827,7 @@ async function adoptPtyHostSessions() {
3627
3827
  activeProgram: null,
3628
3828
  agent: null,
3629
3829
  agentSession: null,
3830
+ autoTitleContext: '',
3630
3831
  lastAgentReviewed: null,
3631
3832
  agentLeftAt: null,
3632
3833
  gitStatus: null,
@@ -4036,9 +4237,11 @@ router.patch('/session-inventory/sessions/:frontendSessionId', async (req, res)
4036
4237
  const next = { ...previous, lastActivity: Date.now() };
4037
4238
  if (typeof body.name === 'string' && body.name.trim().length > 0) {
4038
4239
  next.name = body.name.trim();
4240
+ next.autoTitle = null;
4039
4241
  }
4040
4242
  if (typeof body.customName === 'boolean') {
4041
4243
  next.customName = body.customName ? true : undefined;
4244
+ next.autoTitle = null;
4042
4245
  }
4043
4246
  if (Object.prototype.hasOwnProperty.call(body, 'backendSessionId')) {
4044
4247
  next.backendSessionId = typeof body.backendSessionId === 'string' && body.backendSessionId.trim().length > 0
@@ -4166,6 +4369,9 @@ async function getSettingsPayload() {
4166
4369
  networkAvailable: caffeinateManager.isNetworkAvailable(),
4167
4370
  locale: getLocaleSetting(),
4168
4371
  contextDraftHeight: getContextDraftHeightSetting(),
4372
+ autoRenameAgents: getAutoRenameAgentsSetting(),
4373
+ autoRenameNamer: getAutoRenameNamerSetting(),
4374
+ autoRenameModels: getAutoRenameModelsSetting(),
4169
4375
  localAccess: {
4170
4376
  ...localAccess,
4171
4377
  interfaces,
@@ -4177,6 +4383,25 @@ async function getSettingsPayload() {
4177
4383
  router.get('/settings', async (_req, res) => {
4178
4384
  res.json(await getSettingsPayload());
4179
4385
  });
4386
+ router.get('/auto-title/catalog', async (req, res) => {
4387
+ const force = req.query.refresh === '1';
4388
+ res.json({ namers: await getTitleNamerCatalog(force) });
4389
+ });
4390
+ router.get('/update', (_req, res) => {
4391
+ res.json(npmAutoUpdateManager.getState());
4392
+ });
4393
+ router.post('/update/check', async (_req, res) => {
4394
+ res.json(await npmAutoUpdateManager.checkNow());
4395
+ });
4396
+ router.post('/update/restart', (_req, res) => {
4397
+ try {
4398
+ const state = npmAutoUpdateManager.confirmRestart();
4399
+ res.status(202).json(state);
4400
+ }
4401
+ catch (error) {
4402
+ res.status(409).json({ error: error instanceof Error ? error.message : String(error) });
4403
+ }
4404
+ });
4180
4405
  router.put('/settings', async (req, res) => {
4181
4406
  const body = req.body ?? {};
4182
4407
  if (typeof body.preventSleep === 'boolean') {
@@ -4197,6 +4422,36 @@ router.put('/settings', async (req, res) => {
4197
4422
  }
4198
4423
  }
4199
4424
  }
4425
+ if (Array.isArray(body.autoRenameAgents)) {
4426
+ const knownSlugs = new Set(listAgents().map((agent) => agent.slug));
4427
+ const requested = body.autoRenameAgents
4428
+ .filter((slug) => typeof slug === 'string' && knownSlugs.has(slug));
4429
+ setAutoRenameAgentsSetting(requested);
4430
+ }
4431
+ if (typeof body.autoRenameNamer === 'string') {
4432
+ if (!['auto', 'codex', 'claude'].includes(body.autoRenameNamer)) {
4433
+ res.status(400).json({ error: 'Unsupported automatic title agent', code: 'AUTO_RENAME_NAMER_INVALID' });
4434
+ return;
4435
+ }
4436
+ setAutoRenameNamerSetting(body.autoRenameNamer);
4437
+ }
4438
+ if (body.autoRenameModels && typeof body.autoRenameModels === 'object') {
4439
+ const requested = body.autoRenameModels;
4440
+ const catalog = await getTitleNamerCatalog();
4441
+ const allowed = new Map(catalog.map((namer) => [namer.slug, new Set(namer.models.map((model) => model.id))]));
4442
+ const models = {};
4443
+ for (const slug of ['codex', 'claude']) {
4444
+ const model = requested[slug];
4445
+ if (model === undefined || model === '')
4446
+ continue;
4447
+ if (typeof model !== 'string' || !allowed.get(slug)?.has(model)) {
4448
+ res.status(400).json({ error: `Unsupported ${slug} title model`, code: 'AUTO_RENAME_MODEL_INVALID' });
4449
+ return;
4450
+ }
4451
+ models[slug] = model;
4452
+ }
4453
+ setAutoRenameModelsSetting(models);
4454
+ }
4200
4455
  if (body.localAccess && typeof body.localAccess === 'object') {
4201
4456
  const localAccessBody = body.localAccess;
4202
4457
  if (localAccessBody.reset === true) {
@@ -4242,6 +4497,77 @@ router.put('/context-draft', (req, res) => {
4242
4497
  router.get('/agent-hooks', (_req, res) => {
4243
4498
  res.json({ agents: listAllHookAgents() });
4244
4499
  });
4500
+ router.get('/agent-launchers', async (_req, res) => {
4501
+ const pathEntries = buildAugmentedPath().split(path.delimiter).filter(Boolean);
4502
+ const executableExtensions = process.platform === 'win32'
4503
+ ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';')
4504
+ : [''];
4505
+ const detected = [];
4506
+ for (const agent of listAgents()) {
4507
+ let command = null;
4508
+ for (const alias of agent.aliases) {
4509
+ for (const directory of pathEntries) {
4510
+ for (const extension of executableExtensions) {
4511
+ const candidate = path.join(directory, `${alias}${extension}`);
4512
+ try {
4513
+ await fs.promises.access(candidate, process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
4514
+ command = alias;
4515
+ break;
4516
+ }
4517
+ catch { /* keep looking */ }
4518
+ }
4519
+ if (command)
4520
+ break;
4521
+ }
4522
+ if (command)
4523
+ break;
4524
+ }
4525
+ if (command)
4526
+ detected.push({ slug: agent.slug, displayName: agent.displayName, command });
4527
+ }
4528
+ res.json({ agents: detected });
4529
+ });
4530
+ router.get('/directory-suggestions', async (req, res) => {
4531
+ const rawQuery = typeof req.query.q === 'string' ? req.query.q.trim() : '';
4532
+ const expandedQuery = rawQuery === '~'
4533
+ ? os.homedir()
4534
+ : rawQuery.startsWith(`~${path.sep}`)
4535
+ ? path.join(os.homedir(), rawQuery.slice(2))
4536
+ : rawQuery;
4537
+ const absoluteQuery = expandedQuery || os.homedir();
4538
+ const hasTrailingSeparator = absoluteQuery.endsWith(path.sep);
4539
+ const parentCandidate = hasTrailingSeparator ? absoluteQuery : path.dirname(absoluteQuery);
4540
+ const namePrefix = hasTrailingSeparator ? '' : path.basename(absoluteQuery).toLowerCase();
4541
+ try {
4542
+ const parent = req.pathValidator
4543
+ ? await req.pathValidator.validateAsync(parentCandidate)
4544
+ : await fs.promises.realpath(parentCandidate);
4545
+ const entries = await fs.promises.readdir(parent, { withFileTypes: true });
4546
+ const matching = entries
4547
+ .filter((entry) => !entry.name.startsWith('.') && entry.name.toLowerCase().startsWith(namePrefix))
4548
+ .slice(0, 30);
4549
+ const directories = [];
4550
+ for (const entry of matching) {
4551
+ const candidate = path.join(parent, entry.name);
4552
+ if (entry.isDirectory()) {
4553
+ directories.push(candidate);
4554
+ }
4555
+ else if (entry.isSymbolicLink()) {
4556
+ try {
4557
+ if ((await fs.promises.stat(candidate)).isDirectory())
4558
+ directories.push(candidate);
4559
+ }
4560
+ catch { /* omit broken/inaccessible symlinks */ }
4561
+ }
4562
+ if (directories.length >= 10)
4563
+ break;
4564
+ }
4565
+ res.json({ directories });
4566
+ }
4567
+ catch {
4568
+ res.json({ directories: [] });
4569
+ }
4570
+ });
4245
4571
  router.post('/agent-hooks/:agent/install', async (req, res) => {
4246
4572
  const agent = req.params.agent;
4247
4573
  try {
@@ -4277,7 +4603,7 @@ router.get('/agent-plugins', (_req, res) => {
4277
4603
  hasResume: p.manifest.resume !== undefined,
4278
4604
  hasIcon: p.iconPath !== null,
4279
4605
  })),
4280
- errors: errors.map((e) => ({ slug: e.slug, errors: e.errors })),
4606
+ errors: errors.map((e) => ({ slug: e.slug, errors: e.errors, code: e.code, migration: e.migration })),
4281
4607
  });
4282
4608
  });
4283
4609
  router.post('/agent-plugins', async (req, res) => {
@@ -4295,8 +4621,20 @@ router.post('/agent-plugins', async (req, res) => {
4295
4621
  res.status(409).json({ error: `Plugin "${existing.manifest.slug}" already exists` });
4296
4622
  return;
4297
4623
  }
4298
- const dir = savePlugin(manifest);
4299
- res.json({ slug: manifest.slug, dir, state: 'created' });
4624
+ const validation = validateManifest(manifest, path.join(os.homedir(), '.termdock', 'agent-plugins', 'pending'));
4625
+ if ('error' in validation) {
4626
+ res.status(400).json({
4627
+ error: validation.error.errors.join('\n'),
4628
+ code: validation.error.code ?? 'AGENT_PLUGIN_MANIFEST_INVALID',
4629
+ migration: validation.error.migration,
4630
+ });
4631
+ return;
4632
+ }
4633
+ const dir = savePlugin(validation.manifest);
4634
+ clearPluginAgents();
4635
+ const loaded = loadPlugins();
4636
+ registerPluginAgents(loaded.plugins);
4637
+ res.json({ slug: validation.manifest.slug, dir, state: 'created' });
4300
4638
  }
4301
4639
  catch (error) {
4302
4640
  res.status(400).json({ error: error.message });
@@ -4306,6 +4644,8 @@ router.delete('/agent-plugins/:slug', async (req, res) => {
4306
4644
  const { slug } = req.params;
4307
4645
  try {
4308
4646
  removePlugin(slug);
4647
+ clearPluginAgents();
4648
+ registerPluginAgents(loadPlugins().plugins);
4309
4649
  res.json({ slug, state: 'removed' });
4310
4650
  }
4311
4651
  catch (error) {
@@ -5508,6 +5848,7 @@ export function handleControlWebSocket(ws, clientId) {
5508
5848
  }
5509
5849
  try {
5510
5850
  ws.send(JSON.stringify({ type: 'client-state', seq: initialSeq, state: globalSessionState, inventory: inventory ?? latestSessionInventory }));
5851
+ ws.send(JSON.stringify({ type: 'update-state', state: npmAutoUpdateManager.getState() }));
5511
5852
  }
5512
5853
  catch {
5513
5854
  controlClients.delete(clientId);