termdock 1.4.69 → 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 (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-CG6vLwyw.js} +1 -1
  4. package/dist/client/assets/{abnfDiagram-VRR7QNED-BYiQXGYk.js → abnfDiagram-VRR7QNED-CrXEZLWX.js} +1 -1
  5. package/dist/client/assets/{arc-jlT_jmoZ.js → arc-DbKGR7Ex.js} +1 -1
  6. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-D1qZ875Q.js → architectureDiagram-ZJ3FMSHR-PF5BoUbI.js} +1 -1
  7. package/dist/client/assets/{blockDiagram-677ZJIJ3-qkgn2Ysc.js → blockDiagram-677ZJIJ3-D1gRzVWB.js} +1 -1
  8. package/dist/client/assets/{c4Diagram-LMCZKHZV-JgcH49Pf.js → c4Diagram-LMCZKHZV-CwvOLync.js} +1 -1
  9. package/dist/client/assets/channel-JTEuKVy6.js +1 -0
  10. package/dist/client/assets/{chunk-2Q5K7J3B-E-3iflEy.js → chunk-2Q5K7J3B-BeRdP-xP.js} +1 -1
  11. package/dist/client/assets/{chunk-32BRIVSS-mM01y3kF.js → chunk-32BRIVSS-CrqR5rvl.js} +1 -1
  12. package/dist/client/assets/{chunk-5VM5RSS4-GO8Muo5l.js → chunk-5VM5RSS4-CjbUr-ko.js} +1 -1
  13. package/dist/client/assets/{chunk-EX3LRPZG-BUSDluH0.js → chunk-EX3LRPZG-BVEk3Wma.js} +1 -1
  14. package/dist/client/assets/{chunk-JWPE2WC7-obSL6EzJ.js → chunk-JWPE2WC7-WitJe8kR.js} +1 -1
  15. package/dist/client/assets/{chunk-MOJQB5TN-zEktbtAG.js → chunk-MOJQB5TN-BCewlLYk.js} +1 -1
  16. package/dist/client/assets/{chunk-RYQCIY6F-kH7H8XgQ.js → chunk-RYQCIY6F-CoP0AA4G.js} +1 -1
  17. package/dist/client/assets/{chunk-V7JOEXUC-BqDFpHKN.js → chunk-V7JOEXUC-CSZrnmp_.js} +1 -1
  18. package/dist/client/assets/{chunk-VR4S4FIN-BGZ7kCIG.js → chunk-VR4S4FIN-BB-7zz-9.js} +1 -1
  19. package/dist/client/assets/{chunk-XXDRQBXY-D-TatWXe.js → chunk-XXDRQBXY-CcZZlCik.js} +1 -1
  20. package/dist/client/assets/classDiagram-OUVF2IWQ-BiFJB_5s.js +1 -0
  21. package/dist/client/assets/classDiagram-v2-EOCWNBFH-BiFJB_5s.js +1 -0
  22. package/dist/client/assets/{cose-bilkent-JH36ORCC-CfWyrViz.js → cose-bilkent-JH36ORCC-DcmzUDwh.js} +1 -1
  23. package/dist/client/assets/{cynefin-VYW2F7L2-BFdZ3FcE.js → cynefin-VYW2F7L2-C1XlDVoJ.js} +1 -1
  24. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-iAfCVoFU.js → cynefinDiagram-TSTJHNR4-zY6Aet2S.js} +1 -1
  25. package/dist/client/assets/{dagre-VKFMJZFB-CZhh0hrr.js → dagre-VKFMJZFB-DqGTi6zR.js} +1 -1
  26. package/dist/client/assets/{diagram-FQU43EPY-BqUK2OmV.js → diagram-FQU43EPY-DStcsU7S.js} +1 -1
  27. package/dist/client/assets/{diagram-G47NLZAW-vC0pSQYC.js → diagram-G47NLZAW-Cs6IMNip.js} +1 -1
  28. package/dist/client/assets/{diagram-NH7WQ7WH-Dqc5oxed.js → diagram-NH7WQ7WH-yA2bpWN7.js} +1 -1
  29. package/dist/client/assets/{diagram-OA4YK3LP-DIIXckFE.js → diagram-OA4YK3LP-Dzf9M0PI.js} +1 -1
  30. package/dist/client/assets/{diagram-WEI45ONY-DR9_cFeh.js → diagram-WEI45ONY-D75lpQKA.js} +1 -1
  31. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-21OO0OXC.js → ebnfDiagram-CCIWWBDH-CkQvIpWy.js} +1 -1
  32. package/dist/client/assets/{erDiagram-Q63AITRT-YKnVG3az.js → erDiagram-Q63AITRT-Ca00Vjxz.js} +1 -1
  33. package/dist/client/assets/{flowDiagram-23GEKE2U-Cm7TGErF.js → flowDiagram-23GEKE2U-CGrnSTEp.js} +1 -1
  34. package/dist/client/assets/{ganttDiagram-NO4QXBWP-D_76Effz.js → ganttDiagram-NO4QXBWP-0SHfOIP4.js} +1 -1
  35. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-CC1ysmVD.js → gitGraphDiagram-IHSO6WYX-BeigYWvs.js} +1 -1
  36. package/dist/client/assets/index-D5PKdbhW.js +451 -0
  37. package/dist/client/assets/index-DhfIiBLW.css +1 -0
  38. package/dist/client/assets/{infoDiagram-FWYZ7A6U-BbgpmhBz.js → infoDiagram-FWYZ7A6U-CiY1-xjN.js} +1 -1
  39. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-C94bY_je.js → ishikawaDiagram-FXEZZL3T-CmtDjUm_.js} +1 -1
  40. package/dist/client/assets/{journeyDiagram-5HDEW3XC-BQ0iFRxs.js → journeyDiagram-5HDEW3XC-B7qOr9LY.js} +1 -1
  41. package/dist/client/assets/{kanban-definition-HUTT4EX6-BtAexXPo.js → kanban-definition-HUTT4EX6-DO2P2Um3.js} +1 -1
  42. package/dist/client/assets/{linear-C2HnIdOK.js → linear-B832hJt0.js} +1 -1
  43. package/dist/client/assets/{mermaid.core-BbG4sJBF.js → mermaid.core-CsAzdiCU.js} +4 -4
  44. package/dist/client/assets/{mindmap-definition-LN4V7U3C-B-uvViws.js → mindmap-definition-LN4V7U3C-HI__vXhQ.js} +1 -1
  45. package/dist/client/assets/{pegDiagram-2B236MQR-DHYcR6tE.js → pegDiagram-2B236MQR--rZIwjXq.js} +1 -1
  46. package/dist/client/assets/{pieDiagram-ENE6RG2P-DEE9cD6M.js → pieDiagram-ENE6RG2P-D9jPf5x9.js} +1 -1
  47. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-DMQP5jG3.js → quadrantDiagram-ABIIQ3AL-DTCP5ji2.js} +1 -1
  48. package/dist/client/assets/{railroadDiagram-RFXS5EU6-B-4WDTwc.js → railroadDiagram-RFXS5EU6-D2Gspvrl.js} +1 -1
  49. package/dist/client/assets/{requirementDiagram-TGXJPOKE-C2xw_q-R.js → requirementDiagram-TGXJPOKE-DEJC02IW.js} +1 -1
  50. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-wq0BPqhc.js → sankeyDiagram-HTMAVEWB-opOhKPap.js} +1 -1
  51. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-BqpM0A9d.js → sequenceDiagram-DBY2YBRQ-_sz2CRAG.js} +1 -1
  52. package/dist/client/assets/{sizeCapture-X5ZJPWSS-Ba2xxyp_.js → sizeCapture-X5ZJPWSS-D0Q2QkYF.js} +1 -1
  53. package/dist/client/assets/{stateDiagram-2N3HPSRC-agPx5qAN.js → stateDiagram-2N3HPSRC-VgeSQTtW.js} +1 -1
  54. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-BVh2FRO-.js +1 -0
  55. package/dist/client/assets/{swimlanes-5IMT3BWC-Cy6Lzj_L.js → swimlanes-5IMT3BWC-ClvBvSl0.js} +2 -2
  56. package/dist/client/assets/swimlanesDiagram-G3AALYLV-C17V-kgm.js +8 -0
  57. package/dist/client/assets/{timeline-definition-FHXFAJF6-LBrVfbdp.js → timeline-definition-FHXFAJF6-BXNsrECV.js} +1 -1
  58. package/dist/client/assets/{vennDiagram-L72KCM5P-BiYIV7mX.js → vennDiagram-L72KCM5P-BgwiqZd-.js} +1 -1
  59. package/dist/client/assets/{wardleyDiagram-EHGQE667-Q1QU9gG3.js → wardleyDiagram-EHGQE667-DiqN5ERx.js} +1 -1
  60. package/dist/client/assets/{xychartDiagram-FW5EYKEG-CzYpxk4l.js → xychartDiagram-FW5EYKEG-DQjFVqC6.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 +95 -0
  64. package/dist/server/agent/installers.js +106 -22
  65. package/dist/server/agent/plugins.js +135 -4
  66. package/dist/server/agent/registry.js +2 -1
  67. package/dist/server/agent/session.js +27 -2
  68. package/dist/server/agent/titleNamerCatalog.js +301 -0
  69. package/dist/server/agentHook.js +49 -1
  70. package/dist/server/cli.js +238 -30
  71. package/dist/server/routes/filesystem.js +11 -8
  72. package/dist/server/routes/terminal.js +382 -14
  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 +43 -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';
23
- import { applyAgentEvent, defaultAgentSessionState, parseAgentEvent, } from '../agent/session.js';
22
+ import { agentBySlug, buildResumeCommand, detectAgentFromCommand, listAgents, } from '../agent/registry.js';
23
+ import { applyAgentEvent, agentEventMatchesCurrentAgent, 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 { AUTO_TITLE_MIN_CONTEXT_CHARS, cleanTerminalContext, generateAgentTitle, isNewAgentSessionId, shouldReplaceAutoTitle, } from '../agent/autoTitle.js';
32
+ import { getTitleNamerCatalog, invalidateTitleNamerCatalog } 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,102 @@ 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
+ // A new session gets one cheap attempt after its first real exchange. Short
2162
+ // conversations ("hi" plus a brief reply) are still valid sessions; the
2163
+ // one-hour re-evaluation guard below keeps this from becoming noisy after a
2164
+ // title exists.
2165
+ if (context.length < AUTO_TITLE_MIN_CONTEXT_CHARS)
2166
+ return;
2167
+ const contentHash = createHash('sha256').update(context).digest('hex');
2168
+ if (record.autoTitle?.contentHash === contentHash)
2169
+ return;
2170
+ autoTitlePending.add(sessionId);
2171
+ try {
2172
+ const savedModels = getAutoRenameModelsSetting();
2173
+ const catalog = await getTitleNamerCatalog();
2174
+ const availableModels = new Map(catalog.map((namer) => [
2175
+ namer.slug,
2176
+ new Set(namer.models.map((model) => model.id)),
2177
+ ]));
2178
+ const validModels = Object.fromEntries(Object.entries(savedModels)
2179
+ .filter(([slug, model]) => availableModels.get(slug)?.has(model)));
2180
+ for (const namer of catalog) {
2181
+ if (!validModels[namer.slug] && namer.recommendedModel) {
2182
+ validModels[namer.slug] = namer.recommendedModel;
2183
+ }
2184
+ }
2185
+ const title = await generateAgentTitle(agent.slug, agent.displayName, context, {
2186
+ namer: getAutoRenameNamerSetting(),
2187
+ models: validModels,
2188
+ currentTitle: record.autoTitle ? record.name : undefined,
2189
+ });
2190
+ if (!title)
2191
+ return;
2192
+ // A manual rename performed while the namer was running always wins.
2193
+ const current = globalSessionState.sessions.find((item) => item.backendSessionId === sessionId);
2194
+ if (!current || (current.customName === true && !current.autoTitle))
2195
+ return;
2196
+ if (current.autoTitle && !shouldReplaceAutoTitle(current.name, title)) {
2197
+ upsertGlobalSessionRecord({
2198
+ ...current,
2199
+ autoTitle: { agentSlug: agent.slug, contentHash, updatedAt: Date.now() },
2200
+ });
2201
+ await persistGlobalStateNow();
2202
+ console.log(`[auto-title] kept ${sessionId} title ${JSON.stringify(current.name)}`);
2203
+ return;
2204
+ }
2205
+ const next = {
2206
+ ...current,
2207
+ name: title,
2208
+ customName: true,
2209
+ autoTitle: { agentSlug: agent.slug, contentHash, updatedAt: Date.now() },
2210
+ };
2211
+ if (next.mode === 'tmux' && next.tmuxSessionName) {
2212
+ await setTmuxOption(next.tmuxSessionName, '@termdock-friendly-name', title);
2213
+ }
2214
+ upsertGlobalSessionRecord(next);
2215
+ await persistGlobalStateNow();
2216
+ broadcastClientState();
2217
+ console.log(`[auto-title] renamed ${sessionId} (${agent.slug}) to ${JSON.stringify(title)}`);
2218
+ }
2219
+ catch (error) {
2220
+ console.warn(`[auto-title] failed for ${sessionId}: ${getErrorMessage(error)}`);
2221
+ }
2222
+ finally {
2223
+ autoTitlePending.delete(sessionId);
2224
+ }
2225
+ }
2057
2226
  /** Status → indicator glyph. Colors stay client-side (CSS theme vars) so
2058
2227
  * dark/light themes render correctly; the wire carries semantics only. */
2059
2228
  const AGENT_STATUS_INDICATOR = {
@@ -2074,7 +2243,8 @@ function isPaneAtShellPrompt(session) {
2074
2243
  function buildAgentStatusPayload(sessionId, session) {
2075
2244
  const state = session.agentSession;
2076
2245
  const status = state ? state.status : null;
2077
- const indicator = status ? AGENT_STATUS_INDICATOR[status] : null;
2246
+ const presentation = state?.presentation ?? null;
2247
+ const indicator = presentation?.indicator ?? (status ? AGENT_STATUS_INDICATOR[status] : null);
2078
2248
  let agent = session.agent;
2079
2249
  let nativeSessionId = state?.sessionId ?? null;
2080
2250
  // The agent just exited: keep offering its last conversation for resume
@@ -2094,6 +2264,9 @@ function buildAgentStatusPayload(sessionId, session) {
2094
2264
  type: 'agent-status',
2095
2265
  agentStatus: status,
2096
2266
  agentIndicator: indicator ?? null,
2267
+ agentStatusDetail: presentation
2268
+ ? { id: presentation.id, label: presentation.label, tone: presentation.tone ?? 'neutral' }
2269
+ : null,
2097
2270
  agent: agent
2098
2271
  ? { slug: agent.slug, displayName: agent.displayName, accentColor: agent.accentColor, icon: agent.icon, isPlugin: agent.isPlugin ?? false, iconMode: agent.iconMode, iconVersion: agent.iconVersion }
2099
2272
  : null,
@@ -2218,7 +2391,10 @@ function applyAgentSignals(sessionId, session, events, notification) {
2218
2391
  // that a poll blip may have started in syncAgentIdentity.
2219
2392
  session.agentLeftAt = null;
2220
2393
  let identityChanged = false;
2394
+ let completedTurnAgent = null;
2221
2395
  for (const event of events) {
2396
+ if (!agentEventMatchesCurrentAgent(session.agent, event))
2397
+ continue;
2222
2398
  // An event naming an agent brands the pane even when the process poll
2223
2399
  // can't see through a wrapper: identity via protocol.
2224
2400
  if (!session.agent && event.agent) {
@@ -2227,7 +2403,25 @@ function applyAgentSignals(sessionId, session, events, notification) {
2227
2403
  }
2228
2404
  const state = session.agentSession ?? defaultAgentSessionState();
2229
2405
  session.agentSession = state;
2406
+ const previousNativeSessionId = state.sessionId
2407
+ ?? globalSessionState.sessions.find((item) => item.backendSessionId === sessionId)?.agentResume?.sessionId
2408
+ ?? null;
2409
+ if (event.kind === 'session-start' && isNewAgentSessionId(previousNativeSessionId, event.sessionId)) {
2410
+ clearAutoTitleForNewAgentSession(sessionId, session);
2411
+ }
2412
+ if (event.kind === 'prompt-submit') {
2413
+ // Before the first automatic title, keep accumulating short turns until
2414
+ // there is enough substance to name the session. Once titled, only the
2415
+ // latest turn is relevant to the conservative re-evaluation path.
2416
+ const persisted = globalSessionState.sessions.find((item) => item.backendSessionId === sessionId);
2417
+ if (persisted?.autoTitle) {
2418
+ session.autoTitleContext = '';
2419
+ }
2420
+ }
2230
2421
  applyAgentEvent(state, event);
2422
+ if (event.kind === 'stop') {
2423
+ completedTurnAgent = event.agent ?? session.agent;
2424
+ }
2231
2425
  if (event.kind === 'session-start' || event.kind === 'session-end' || event.kind === 'stop') {
2232
2426
  persistAgentResumeBinding(sessionId, session);
2233
2427
  }
@@ -2259,6 +2453,13 @@ function applyAgentSignals(sessionId, session, events, notification) {
2259
2453
  // one of the git probe's triggers.
2260
2454
  maybeRefreshGitStatusForAgent(sessionId, session);
2261
2455
  broadcastAgentStatus(sessionId, session, identityChanged);
2456
+ if (completedTurnAgent) {
2457
+ const agent = completedTurnAgent;
2458
+ // Let the current PTY chunk reach the rolling context before reading it.
2459
+ setTimeout(() => {
2460
+ void maybeAutoRenameSession(sessionId, agent, session.autoTitleContext);
2461
+ }, 250).unref?.();
2462
+ }
2262
2463
  }
2263
2464
  // ── Git status (branch + diff size) ──
2264
2465
  //
@@ -2407,6 +2608,9 @@ try {
2407
2608
  }
2408
2609
  for (const err of errors) {
2409
2610
  console.warn(`[agent-plugins] validation error in "${err.slug}": ${err.errors.join('; ')}`);
2611
+ if (err.migration?.aiPrompt) {
2612
+ console.warn(`[agent-plugins] AI migration prompt for "${err.slug}": ${err.migration.aiPrompt}`);
2613
+ }
2410
2614
  }
2411
2615
  }
2412
2616
  catch { /* plugin loading must never block startup */ }
@@ -3376,6 +3580,7 @@ function processSessionOutput(sessionId, session, data, opts) {
3376
3580
  }
3377
3581
  catch { /* sniff failure should never block data */ }
3378
3582
  if (opts.broadcast) {
3583
+ appendAutoTitleContext(session, data);
3379
3584
  broadcastEvent(sessionId, seq !== undefined ? { type: 'data', data, seq } : { type: 'data', data });
3380
3585
  }
3381
3586
  }
@@ -3546,6 +3751,7 @@ async function spawnTerminalSession(req, input) {
3546
3751
  activeProgram: null,
3547
3752
  agent: null,
3548
3753
  agentSession: null,
3754
+ autoTitleContext: '',
3549
3755
  lastAgentReviewed: null,
3550
3756
  agentLeftAt: null,
3551
3757
  gitStatus: null,
@@ -3627,6 +3833,7 @@ async function adoptPtyHostSessions() {
3627
3833
  activeProgram: null,
3628
3834
  agent: null,
3629
3835
  agentSession: null,
3836
+ autoTitleContext: '',
3630
3837
  lastAgentReviewed: null,
3631
3838
  agentLeftAt: null,
3632
3839
  gitStatus: null,
@@ -4036,9 +4243,11 @@ router.patch('/session-inventory/sessions/:frontendSessionId', async (req, res)
4036
4243
  const next = { ...previous, lastActivity: Date.now() };
4037
4244
  if (typeof body.name === 'string' && body.name.trim().length > 0) {
4038
4245
  next.name = body.name.trim();
4246
+ next.autoTitle = null;
4039
4247
  }
4040
4248
  if (typeof body.customName === 'boolean') {
4041
4249
  next.customName = body.customName ? true : undefined;
4250
+ next.autoTitle = null;
4042
4251
  }
4043
4252
  if (Object.prototype.hasOwnProperty.call(body, 'backendSessionId')) {
4044
4253
  next.backendSessionId = typeof body.backendSessionId === 'string' && body.backendSessionId.trim().length > 0
@@ -4166,6 +4375,9 @@ async function getSettingsPayload() {
4166
4375
  networkAvailable: caffeinateManager.isNetworkAvailable(),
4167
4376
  locale: getLocaleSetting(),
4168
4377
  contextDraftHeight: getContextDraftHeightSetting(),
4378
+ autoRenameAgents: getAutoRenameAgentsSetting(),
4379
+ autoRenameNamer: getAutoRenameNamerSetting(),
4380
+ autoRenameModels: getAutoRenameModelsSetting(),
4169
4381
  localAccess: {
4170
4382
  ...localAccess,
4171
4383
  interfaces,
@@ -4177,6 +4389,25 @@ async function getSettingsPayload() {
4177
4389
  router.get('/settings', async (_req, res) => {
4178
4390
  res.json(await getSettingsPayload());
4179
4391
  });
4392
+ router.get('/auto-title/catalog', async (req, res) => {
4393
+ const force = req.query.refresh === '1';
4394
+ res.json({ namers: await getTitleNamerCatalog(force) });
4395
+ });
4396
+ router.get('/update', (_req, res) => {
4397
+ res.json(npmAutoUpdateManager.getState());
4398
+ });
4399
+ router.post('/update/check', async (_req, res) => {
4400
+ res.json(await npmAutoUpdateManager.checkNow());
4401
+ });
4402
+ router.post('/update/restart', (_req, res) => {
4403
+ try {
4404
+ const state = npmAutoUpdateManager.confirmRestart();
4405
+ res.status(202).json(state);
4406
+ }
4407
+ catch (error) {
4408
+ res.status(409).json({ error: error instanceof Error ? error.message : String(error) });
4409
+ }
4410
+ });
4180
4411
  router.put('/settings', async (req, res) => {
4181
4412
  const body = req.body ?? {};
4182
4413
  if (typeof body.preventSleep === 'boolean') {
@@ -4197,6 +4428,36 @@ router.put('/settings', async (req, res) => {
4197
4428
  }
4198
4429
  }
4199
4430
  }
4431
+ if (Array.isArray(body.autoRenameAgents)) {
4432
+ const knownSlugs = new Set(listAgents().map((agent) => agent.slug));
4433
+ const requested = body.autoRenameAgents
4434
+ .filter((slug) => typeof slug === 'string' && knownSlugs.has(slug));
4435
+ setAutoRenameAgentsSetting(requested);
4436
+ }
4437
+ if (typeof body.autoRenameNamer === 'string') {
4438
+ const available = new Set((await getTitleNamerCatalog()).filter((entry) => entry.available).map((entry) => entry.slug));
4439
+ if (body.autoRenameNamer !== 'auto' && !available.has(body.autoRenameNamer)) {
4440
+ res.status(400).json({ error: 'Unsupported automatic title agent', code: 'AUTO_RENAME_NAMER_INVALID' });
4441
+ return;
4442
+ }
4443
+ setAutoRenameNamerSetting(body.autoRenameNamer);
4444
+ }
4445
+ if (body.autoRenameModels && typeof body.autoRenameModels === 'object') {
4446
+ const requested = body.autoRenameModels;
4447
+ const catalog = await getTitleNamerCatalog();
4448
+ const allowed = new Map(catalog.map((namer) => [namer.slug, new Set(namer.models.map((model) => model.id))]));
4449
+ const models = {};
4450
+ for (const [slug, model] of Object.entries(requested)) {
4451
+ if (model === undefined || model === '')
4452
+ continue;
4453
+ if (typeof model !== 'string' || !allowed.get(slug)?.has(model)) {
4454
+ res.status(400).json({ error: `Unsupported ${slug} title model`, code: 'AUTO_RENAME_MODEL_INVALID' });
4455
+ return;
4456
+ }
4457
+ models[slug] = model;
4458
+ }
4459
+ setAutoRenameModelsSetting(models);
4460
+ }
4200
4461
  if (body.localAccess && typeof body.localAccess === 'object') {
4201
4462
  const localAccessBody = body.localAccess;
4202
4463
  if (localAccessBody.reset === true) {
@@ -4239,8 +4500,79 @@ router.put('/context-draft', (req, res) => {
4239
4500
  res.json(doc);
4240
4501
  });
4241
4502
  // ── Agent hooks API (rich status channel installers, built-in + plugin) ──
4242
- router.get('/agent-hooks', (_req, res) => {
4243
- res.json({ agents: listAllHookAgents() });
4503
+ router.get('/agent-hooks', async (_req, res) => {
4504
+ res.json({ agents: await listAllHookAgents() });
4505
+ });
4506
+ router.get('/agent-launchers', async (_req, res) => {
4507
+ const pathEntries = buildAugmentedPath().split(path.delimiter).filter(Boolean);
4508
+ const executableExtensions = process.platform === 'win32'
4509
+ ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';')
4510
+ : [''];
4511
+ const detected = [];
4512
+ for (const agent of listAgents()) {
4513
+ let command = null;
4514
+ for (const alias of agent.aliases) {
4515
+ for (const directory of pathEntries) {
4516
+ for (const extension of executableExtensions) {
4517
+ const candidate = path.join(directory, `${alias}${extension}`);
4518
+ try {
4519
+ await fs.promises.access(candidate, process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
4520
+ command = alias;
4521
+ break;
4522
+ }
4523
+ catch { /* keep looking */ }
4524
+ }
4525
+ if (command)
4526
+ break;
4527
+ }
4528
+ if (command)
4529
+ break;
4530
+ }
4531
+ if (command)
4532
+ detected.push({ slug: agent.slug, displayName: agent.displayName, command });
4533
+ }
4534
+ res.json({ agents: detected });
4535
+ });
4536
+ router.get('/directory-suggestions', async (req, res) => {
4537
+ const rawQuery = typeof req.query.q === 'string' ? req.query.q.trim() : '';
4538
+ const expandedQuery = rawQuery === '~'
4539
+ ? os.homedir()
4540
+ : rawQuery.startsWith(`~${path.sep}`)
4541
+ ? path.join(os.homedir(), rawQuery.slice(2))
4542
+ : rawQuery;
4543
+ const absoluteQuery = expandedQuery || os.homedir();
4544
+ const hasTrailingSeparator = absoluteQuery.endsWith(path.sep);
4545
+ const parentCandidate = hasTrailingSeparator ? absoluteQuery : path.dirname(absoluteQuery);
4546
+ const namePrefix = hasTrailingSeparator ? '' : path.basename(absoluteQuery).toLowerCase();
4547
+ try {
4548
+ const parent = req.pathValidator
4549
+ ? await req.pathValidator.validateAsync(parentCandidate)
4550
+ : await fs.promises.realpath(parentCandidate);
4551
+ const entries = await fs.promises.readdir(parent, { withFileTypes: true });
4552
+ const matching = entries
4553
+ .filter((entry) => !entry.name.startsWith('.') && entry.name.toLowerCase().startsWith(namePrefix))
4554
+ .slice(0, 30);
4555
+ const directories = [];
4556
+ for (const entry of matching) {
4557
+ const candidate = path.join(parent, entry.name);
4558
+ if (entry.isDirectory()) {
4559
+ directories.push(candidate);
4560
+ }
4561
+ else if (entry.isSymbolicLink()) {
4562
+ try {
4563
+ if ((await fs.promises.stat(candidate)).isDirectory())
4564
+ directories.push(candidate);
4565
+ }
4566
+ catch { /* omit broken/inaccessible symlinks */ }
4567
+ }
4568
+ if (directories.length >= 10)
4569
+ break;
4570
+ }
4571
+ res.json({ directories });
4572
+ }
4573
+ catch {
4574
+ res.json({ directories: [] });
4575
+ }
4244
4576
  });
4245
4577
  router.post('/agent-hooks/:agent/install', async (req, res) => {
4246
4578
  const agent = req.params.agent;
@@ -4275,9 +4607,10 @@ router.get('/agent-plugins', (_req, res) => {
4275
4607
  iconMode: p.manifest.iconMode ?? 'mask',
4276
4608
  hasHooks: p.manifest.hooks !== undefined,
4277
4609
  hasResume: p.manifest.resume !== undefined,
4610
+ hasTitleNamer: p.manifest.titleNamer !== undefined,
4278
4611
  hasIcon: p.iconPath !== null,
4279
4612
  })),
4280
- errors: errors.map((e) => ({ slug: e.slug, errors: e.errors })),
4613
+ errors: errors.map((e) => ({ slug: e.slug, errors: e.errors, code: e.code, migration: e.migration })),
4281
4614
  });
4282
4615
  });
4283
4616
  router.post('/agent-plugins', async (req, res) => {
@@ -4295,8 +4628,21 @@ router.post('/agent-plugins', async (req, res) => {
4295
4628
  res.status(409).json({ error: `Plugin "${existing.manifest.slug}" already exists` });
4296
4629
  return;
4297
4630
  }
4298
- const dir = savePlugin(manifest);
4299
- res.json({ slug: manifest.slug, dir, state: 'created' });
4631
+ const validation = validateManifest(manifest, path.join(os.homedir(), '.termdock', 'agent-plugins', 'pending'));
4632
+ if ('error' in validation) {
4633
+ res.status(400).json({
4634
+ error: validation.error.errors.join('\n'),
4635
+ code: validation.error.code ?? 'AGENT_PLUGIN_MANIFEST_INVALID',
4636
+ migration: validation.error.migration,
4637
+ });
4638
+ return;
4639
+ }
4640
+ const dir = savePlugin(validation.manifest);
4641
+ invalidateTitleNamerCatalog();
4642
+ clearPluginAgents();
4643
+ const loaded = loadPlugins();
4644
+ registerPluginAgents(loaded.plugins);
4645
+ res.json({ slug: validation.manifest.slug, dir, state: 'created' });
4300
4646
  }
4301
4647
  catch (error) {
4302
4648
  res.status(400).json({ error: error.message });
@@ -4305,11 +4651,32 @@ router.post('/agent-plugins', async (req, res) => {
4305
4651
  router.delete('/agent-plugins/:slug', async (req, res) => {
4306
4652
  const { slug } = req.params;
4307
4653
  try {
4654
+ const plugin = loadPlugins().plugins.find((entry) => entry.manifest.slug === slug);
4655
+ if (!plugin) {
4656
+ res.status(404).json({ error: `Plugin "${slug}" not found` });
4657
+ return;
4658
+ }
4659
+ // Uninstall while the manifest is still present: after deletion we no
4660
+ // longer know the target file or event shape and would orphan commands in
4661
+ // the Agent's config.
4662
+ if (plugin.manifest.hooks)
4663
+ await uninstallHooksForSlug(slug);
4308
4664
  removePlugin(slug);
4665
+ invalidateTitleNamerCatalog();
4666
+ setAutoRenameAgentsSetting(getAutoRenameAgentsSetting().filter((entry) => entry !== slug));
4667
+ if (getAutoRenameNamerSetting() === slug)
4668
+ setAutoRenameNamerSetting('auto');
4669
+ const models = getAutoRenameModelsSetting();
4670
+ if (models[slug]) {
4671
+ delete models[slug];
4672
+ setAutoRenameModelsSetting(models);
4673
+ }
4674
+ clearPluginAgents();
4675
+ registerPluginAgents(loadPlugins().plugins);
4309
4676
  res.json({ slug, state: 'removed' });
4310
4677
  }
4311
4678
  catch (error) {
4312
- res.status(404).json({ error: error.message });
4679
+ res.status(400).json({ error: error.message });
4313
4680
  }
4314
4681
  });
4315
4682
  router.get('/agent-plugin-icon/:slug', (req, res) => {
@@ -5508,6 +5875,7 @@ export function handleControlWebSocket(ws, clientId) {
5508
5875
  }
5509
5876
  try {
5510
5877
  ws.send(JSON.stringify({ type: 'client-state', seq: initialSeq, state: globalSessionState, inventory: inventory ?? latestSessionInventory }));
5878
+ ws.send(JSON.stringify({ type: 'update-state', state: npmAutoUpdateManager.getState() }));
5511
5879
  }
5512
5880
  catch {
5513
5881
  controlClients.delete(clientId);