dsh-ssh-tui 0.7.2 → 0.7.3

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/README.en.md +16 -1
  2. package/README.md +14 -1
  3. package/cordis.patch.yml +15 -0
  4. package/docs/terminals.md +113 -0
  5. package/lib/diag.js +35 -0
  6. package/lib/diag.js.map +1 -1
  7. package/lib/display-sock.js +259 -42
  8. package/lib/display-sock.js.map +1 -1
  9. package/lib/doctor.js +71 -37
  10. package/lib/doctor.js.map +1 -1
  11. package/lib/dsh-compat.js +206 -88
  12. package/lib/dsh-compat.js.map +1 -1
  13. package/lib/footer.js +4 -2
  14. package/lib/footer.js.map +1 -1
  15. package/lib/i18n/en.js +18 -0
  16. package/lib/i18n/en.js.map +1 -1
  17. package/lib/i18n/index.js +13 -7
  18. package/lib/i18n/index.js.map +1 -1
  19. package/lib/i18n/zh.js +18 -0
  20. package/lib/i18n/zh.js.map +1 -1
  21. package/lib/index.js +36 -11
  22. package/lib/index.js.map +1 -1
  23. package/lib/paint.js +5 -2
  24. package/lib/paint.js.map +1 -1
  25. package/lib/picker.js +5 -3
  26. package/lib/picker.js.map +1 -1
  27. package/lib/platform.js +296 -11
  28. package/lib/platform.js.map +1 -1
  29. package/lib/preset-authoring.js +10 -14
  30. package/lib/preset-authoring.js.map +1 -1
  31. package/lib/preset-compat.js +100 -0
  32. package/lib/preset-compat.js.map +1 -0
  33. package/lib/preset-picker.js +5 -1
  34. package/lib/preset-picker.js.map +1 -1
  35. package/lib/preset-rows.js +83 -13
  36. package/lib/preset-rows.js.map +1 -1
  37. package/lib/provider-catalog.js +4 -4
  38. package/lib/route-memory.js +3 -3
  39. package/lib/route-memory.js.map +1 -1
  40. package/lib/session-index.js +5 -0
  41. package/lib/session-index.js.map +1 -1
  42. package/lib/session-lock.js +4 -1
  43. package/lib/session-lock.js.map +1 -1
  44. package/lib/session-route.js +3 -0
  45. package/lib/session-route.js.map +1 -1
  46. package/lib/settings-routes.js +10 -0
  47. package/lib/settings-routes.js.map +1 -0
  48. package/lib/settings-subagent.js +10 -0
  49. package/lib/settings-subagent.js.map +1 -0
  50. package/lib/subagent-model.js +5 -5
  51. package/lib/subagent-model.js.map +1 -1
  52. package/lib/supergrok-token.js +4 -0
  53. package/lib/supergrok-token.js.map +1 -1
  54. package/lib/terminal-caps.js +358 -0
  55. package/lib/terminal-caps.js.map +1 -0
  56. package/lib/tui.js +184 -120
  57. package/lib/tui.js.map +1 -1
  58. package/lib/types/diag.d.ts +18 -1
  59. package/lib/types/display-sock.d.ts +63 -22
  60. package/lib/types/doctor.d.ts +8 -1
  61. package/lib/types/dsh-compat.d.ts +141 -44
  62. package/lib/types/footer.d.ts +3 -1
  63. package/lib/types/i18n/index.d.ts +18 -12
  64. package/lib/types/index.d.ts +45 -0
  65. package/lib/types/picker.d.ts +3 -0
  66. package/lib/types/platform.d.ts +175 -10
  67. package/lib/types/preset-authoring.d.ts +8 -14
  68. package/lib/types/preset-compat.d.ts +58 -0
  69. package/lib/types/preset-picker.d.ts +1 -1
  70. package/lib/types/preset-rows.d.ts +51 -9
  71. package/lib/types/settings-routes.d.ts +16 -0
  72. package/lib/types/settings-subagent.d.ts +24 -0
  73. package/lib/types/subagent-model.d.ts +7 -7
  74. package/lib/types/terminal-caps.d.ts +105 -0
  75. package/lib/types/tui.d.ts +41 -7
  76. package/package.json +82 -55
package/lib/tui.js CHANGED
@@ -22,11 +22,11 @@ import { homedir } from 'node:os';
22
22
  import { dirname, join } from 'node:path';
23
23
  import { createRequire } from 'node:module';
24
24
  import { StringDecoder } from 'node:string_decoder';
25
- import { METADATA_FILE, readPresetMetadata } from '@deepseek-ai/dsh-agent-presets';
25
+ import { METADATA_FILE, readPresetMetadata } from './preset-compat.js';
26
26
  import { credentialRef } from '@deepseek-ai/dsh-credentials';
27
27
  import { createUserMessage, errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
28
28
  import { SessionId } from '@deepseek-ai/dsh-session';
29
- import { commandAcceptsAttachments, forEachSessionEventAsync, isAssistantStreamEvent, isTokenDeltaChunk, listenHostEvent, REPLAY_YIELD_EVERY, sessionEventType, sessionEvents, settingsNamespace, streamChunkOf, streamFirstTokenTime, streamFrameAttemptId, streamFrameOwner, } from './dsh-compat.js';
29
+ import { commandAcceptsAttachments, forEachSessionEventAsync, hostSettingsGeneration, isTokenDeltaChunk, listenHostEvent, readSettingsSection, REPLAY_YIELD_EVERY, sessionEvents, settingsNamespace, streamChunkOf, streamFirstTokenTime, streamFrameAttemptId, streamFrameOwner, toolResultFailed, } from './dsh-compat.js';
30
30
  import { classifyApprovalDetailed, commandForApprovalRequest, isApprovalStatusArg, parseAutoApprovalMode } from './auto-approval.js';
31
31
  import { buildReviewUserMessage, parseReviewOutput, reviewSystemPrompt } from './approval-reviewer.js';
32
32
  import { loadProviderCatalog, mergeProviderEntries } from './provider-catalog.js';
@@ -41,19 +41,20 @@ import { keymapReport, resolveKeymap } from './keymap.js';
41
41
  import { presetLabel, profileFromArgv } from './preset-label.js';
42
42
  import { flattenGroups, groupPresets, optionMatches } from './preset-picker.js';
43
43
  import { isRefusal, planCopy, planDelete, planMetadata, presetDirectory, } from './preset-authoring.js';
44
- import { ensureRosterRows, planDuplicateRepair, planRosterRepair, rosterPatchPath, writePatchWithBackup, ROSTER_ROWS, } from './preset-rows.js';
44
+ import { ensureRosterRows, planDuplicateRepair, planRosterRepair, formsRowsDeclared, rosterPatchPath, rosterRows, writePatchWithBackup, ALL_ROSTER_ROWS, } from './preset-rows.js';
45
45
  import { SessionStatsTracker, statsRowOf } from './stats.js';
46
46
  import { QUESTION_OPTION_KEYS, applyQuestionFilter, backspaceQuestionFilter, clearQuestionFilter, typeQuestionFilter, visibleQuestionIndexes, confirmAnswer, inspectClosesOn, moveQuestionCursor, optionsLength, questionOptionIndex, questionSubmit, selectQuestionOptionByKey, } from './dialogs.js';
47
47
  import { commandSuggestions, localizedCommands } from './commands.js';
48
48
  import { archiveStalePlans, boundTranscriptRows, findLivePlanRow, findMergeableToolRow, findToolRowByCallId, mergeToolCard, planShouldDefaultExpand, windowTranscript, } from './rows.js';
49
49
  import { detachFromSshSession, DisplayHost, isTuiHostProcess, resolveDshHome, sessionSockPath } from './display-sock.js';
50
+ import { displayHomePath, envFileName, hostHasOwnConsole, IS_WINDOWS, restrictPathToUser, usesSigwinch, } from './platform.js';
51
+ import { bracketedPasteSequence, mouseDisableSequence, mouseEnableSequence, terminalCapabilities, } from './terminal-caps.js';
50
52
  import { sameSessionRoute, sessionRouteInput } from './session-route.js';
51
53
  import { GATEWAY_PROTOCOL_SUFFIX, GATEWAY_PROTOCOL_ENDPOINT, GATEWAY_PROTOCOL_PREFERENCE, advertisedProtocols, baseProviderIdOf, declaredProtocol, gatewayModelTable, gatewayServesModel, siblingProviderId, splitModelsByProtocol, } from './gateway-protocol.js';
52
54
  import { applySavedLocale, getLocale, localeDisplayName, localeFromTag, setLocale, t, UI_LOCALE_NAMESPACE, } from './i18n/index.js';
53
55
  import { defaultReasoningEffort } from './reasoning.js';
54
56
  import { checkForPluginUpdate, installPluginLatest } from './update-check.js';
55
57
  import { ROUTE_MEMORY_NAMESPACE, parseRouteMemory, rememberedRouteFor, upsertRememberedRoute, } from './route-memory.js';
56
- import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model';
57
58
  import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, subagentModelMatchesProvider, subagentProviderDiffers, subagentSettingsValue, } from './subagent-model.js';
58
59
  import { resolveFreshSuperGrokToken } from './supergrok-token.js';
59
60
  import { copyTextFromRow, copyTextFromTranscript } from './copy-text.js';
@@ -71,11 +72,11 @@ export { clipAnsiToWidth, cursorVisualPosition, displayWidth, foldInputView, hre
71
72
  export { captureHangupSignals, composePaintOutput, detectSshSession, formatLinkQualityChip, ignoreFurtherHangupSignals, isEscapePrefix, findCursorPositionReply, isHangupErrno, linkQualityOf, linkSignalPips, paintIntervalForRtt, paintLinkLabel, parseCursorPositionReply, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, waitUntilIdleOrTimeout, writeBootSplash, } from './paint.js';
72
73
  export { CONTEXT_IDLE_COMPACT_RATIO, CONTEXT_PRESSURE_DANGER_RATIO, CONTEXT_PRESSURE_WARN_RATIO, CONTEXT_RING_EMPTY, CONTEXT_RING_SEGMENTS, contextPressureAlertText, contextPressureRingColor, contextPressureUsedTokens, contextPressureView, describeProviderRoute, dropFooterQuotaPlanName, fitFooterStatsLine, fitFooterStatusLine, footerActivity, footerIdentityParts, footerStatsGroups, footerSubagentForeign, paintFooterSubagentChip, formatContextPressureChip, formatContextPressureRing, formatContextPressureStatusLine, formatDuration, formatFooterQuota, formatQuotaBar, formatQuotaUnknown, formatStatusReport, providerHasQuotaSurface, quotaWindowTag, shortModelName, shortQuotaPlanName, formatTokens, formatTokensPerSecond, parseContextPressure, promptPressureTokens, providerShortCode, providerUsesLocalOAuth, shouldIdleAutoCompact, subagentRouteLabel, } from './footer.js';
73
74
  export { commandCodePeriodStart, commandCodeSourceFor, crossedQuotaThresholds, formatAccountBalance, formatFooterBalance, formatOpenCodeGoUsage, formatQuotaSnapshot, formatQuotaStatusLine, joinUrl, openCodeSourceFor, parseCommandCodeQuota, parseDeepSeekBalance, parseOpenAiCompatibleBalance, parseOpenCodeGoQuota, parseSuperGrokBilling, preferredQuotaWindow, quotaAlertText, quotaRefreshEverySteps, quotaRefreshEveryTurns, remainingPercentFromUsed, tightestQuotaWindow, } from './quota.js';
74
- export { commandAcceptsAttachments, forEachSessionEvent, isAssistantStreamEvent, isTokenDeltaChunk, listPersistenceHeaders, inspectPersistenceSession, sessionEventType, sessionEvents, settingsNamespace, streamChunkOf, streamFirstTokenTime, streamFrameAttemptId, streamFrameOwner, } from './dsh-compat.js';
75
+ export { commandAcceptsAttachments, forEachSessionEvent, isTokenDeltaChunk, listPersistenceHeaders, inspectPersistenceSession, sessionEvents, settingsNamespace, streamChunkOf, streamFirstTokenTime, streamFrameAttemptId, streamFrameOwner, } from './dsh-compat.js';
75
76
  export { applyTurnEndToPlan, askSummary, cardCategoryOf, compactionHeaderText, formatCompactCommandError, isPromptInjectionMessage, matchTranscriptRows, parseFindQuery, parsePlanTodos, planCloseNudgeText, planDockNote, planIsLive, planTitleFromMarkdown, planTurnLeftOpen, promptInjectionSources, promptInjectionTitle, buildSubagentHeader, clipSubagentActivity, describeSubagentFailure, subagentChipSummary, subagentDisplayName, subagentInspectLines, subagentRowFromSpawnTool, foldSubagentUserLog, todoProgressLabel, todoSummary, } from './plan.js';
76
77
  export { buildToolHeader, canMergeToolCall, compactEditPath, compactToolBursts, compactToolGroups, countDiffAddDel, countDiffLines, countOutputLines, diffMetaDiffs, diffStatToken, friendlyJsonLines, parseExitStatus, presentToolCall, READ_TOOL_NAMES, renderToolDiff, toolBodyFitsWorkspace, toolBodyLines, toolStateColor, toolStateLabel, wrappedToolBodyLineCount, } from './tool-present.js';
77
78
  function discoverProviderModels(llm, request, signal) {
78
- return llm.discoverModels(settingsNamespace('llm-pi-ai'), { ...request, signal }, signal);
79
+ return llm.discoverModels(settingsNamespace('llm-pi-ai'), request, signal);
79
80
  }
80
81
  /**
81
82
  * Ask a gateway which routes each of its models answers (`supported_endpoints`
@@ -111,11 +112,7 @@ async function fetchModelEndpoints(baseURL, apiKey, signal) {
111
112
  return undefined;
112
113
  }
113
114
  }
114
- function installUserQuestionAnswerer(ctx, questions, ask) {
115
- const provider = questions;
116
- if (typeof provider.registerProvider === 'function') {
117
- return provider.registerProvider({ ask });
118
- }
115
+ function installUserQuestionAnswerer(ctx, ask) {
119
116
  return ctx.on('user-questions/request', async (request, next) => {
120
117
  try {
121
118
  return await ask(request);
@@ -128,6 +125,16 @@ function installUserQuestionAnswerer(ctx, questions, ask) {
128
125
  });
129
126
  }
130
127
  const ROUTE_MEMORY_NS = ROUTE_MEMORY_NAMESPACE;
128
+ /**
129
+ * The `agent-default-model` settings entry `/model` falls back to writing.
130
+ *
131
+ * 0.1.5 exports this id as `AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE`; 0.1.7
132
+ * removed the export and let the profile own entry ids, which keeps the name the
133
+ * legacy `settings.yaml` section is imported under. Inlined so one build runs
134
+ * against either host — the primary path, `agentDefaultModel.saveSelection`,
135
+ * is the same call on both.
136
+ */
137
+ const AGENT_DEFAULT_MODEL_NS = settingsNamespace('agent-default-model');
131
138
  /**
132
139
  * The window llm-pi-ai falls back to when neither a model entry nor the
133
140
  * installed catalog sizes a model. The wizard pre-fills it only so a route
@@ -308,7 +315,6 @@ const SUBAGENT_DEFAULT_EFFORT_LABEL = () => t('footer.effortDefault');
308
315
  */
309
316
  const QUOTA_RETRY_MS = 15_000;
310
317
  const RESERVED_BOTTOM_LINES = 3; // input line + stats line + status line
311
- const IS_WINDOWS = process.platform === 'win32';
312
318
  function dshHomeDir() {
313
319
  return resolveDshHome();
314
320
  }
@@ -331,23 +337,9 @@ function hostDshVersion() {
331
337
  return 'unknown';
332
338
  }
333
339
  function displayDshPath(file) {
334
- const home = dshHomeDir();
335
- if (IS_WINDOWS) {
336
- const profile = process.env.USERPROFILE;
337
- if (profile !== undefined && home.toLowerCase().startsWith(profile.toLowerCase())) {
338
- const rest = home.slice(profile.length);
339
- return `%USERPROFILE%${rest}\\${file}`.replaceAll('/', '\\');
340
- }
341
- return `${home}\\${file}`.replaceAll('/', '\\');
342
- }
343
- const userHome = homedir();
344
- if (home === userHome)
345
- return `~/.dsh/${file}`;
346
- if (home.startsWith(`${userHome}/`))
347
- return `~/${home.slice(userHome.length + 1)}/${file}`;
348
- return join(home, file);
340
+ return displayHomePath(dshHomeDir(), file);
349
341
  }
350
- const DSH_ENV_FILE = join(dshHomeDir(), IS_WINDOWS ? 'env.cmd' : 'env.sh');
342
+ const DSH_ENV_FILE = join(dshHomeDir(), envFileName());
351
343
  const DEEPSEEK_LOGO_VARIANTS = [
352
344
  {
353
345
  width: 52,
@@ -654,6 +646,14 @@ export class SshTui {
654
646
  /** Last route reported to the launcher; a repaint must not report it again. */
655
647
  lastSessionRoute;
656
648
  useAlternateScreen;
649
+ /**
650
+ * What the terminal in front of us can do. Read from the environment at
651
+ * construction (see `terminal-caps.ts`): the mouse, bracketed paste and the
652
+ * alternate screen are only claimed where the terminal has them.
653
+ */
654
+ terminalCaps;
655
+ /** The OSC 52 caveat is worth saying once, not after every copy. */
656
+ osc52HintShown = false;
657
657
  agentGone = false;
658
658
  onboarding;
659
659
  commandSuggestions = [];
@@ -842,8 +842,8 @@ export class SshTui {
842
842
  this.disconnectPolicy = config.disconnectPolicy ?? this.readDisconnectPolicy();
843
843
  this.presetId = config.presetId ?? 'standard';
844
844
  this.presetName = presetLabel(this.presetId, config.presetName, config.presetTrust);
845
- this.useAlternateScreen = !this.lineMode
846
- && process.env.DSH_TUI_NO_ALT_SCREEN !== '1' && process.env.DSH_TUI_NO_ALT_SCREEN !== 'true';
845
+ this.terminalCaps = config.terminalCaps ?? terminalCapabilities();
846
+ this.useAlternateScreen = !this.lineMode && this.terminalCaps.alternateScreen;
847
847
  this.paintLink = detectSshSession() ? 'ssh' : 'local';
848
848
  this.paintIntervalMs = resolvePaintIntervalMs(config.paintIntervalMs, process.env, {
849
849
  ssh: this.paintLink === 'ssh',
@@ -861,7 +861,13 @@ export class SshTui {
861
861
  }
862
862
  this.refreshRosterHealth();
863
863
  if (this.rosterMissing) {
864
- this.pushRow({ kind: 'system', text: t('mode.bootMissing') });
864
+ // The missing thing differs by host line: a roster the profile can mount,
865
+ // or the agent-plane rows a 0.1.7 terminal profile owns. The advice has to
866
+ // match what `/mode fix` will actually write.
867
+ this.pushRow({
868
+ kind: 'system',
869
+ text: t(this.settingsGeneration === 'forms' ? 'mode.bootFormsMissing' : 'mode.bootMissing'),
870
+ });
865
871
  }
866
872
  if (config.cwdNotice !== undefined && config.cwdNotice !== '') {
867
873
  this.pushRow({ kind: /进入|Entered/u.test(config.cwdNotice) ? 'system' : 'error', text: config.cwdNotice });
@@ -869,6 +875,14 @@ export class SshTui {
869
875
  if (config.restoredRoute !== undefined) {
870
876
  this.pushRow({ kind: 'system', text: sessionRouteNotice(config.restoredRoute) });
871
877
  }
878
+ // Windows only, and only on the fallback path: with no PowerShell there is
879
+ // no hidden console, so the Host is a direct child and closing the window
880
+ // ends the session's compute. Say it at boot rather than let the user find
881
+ // out by closing the window mid-turn. A bootstrapped Windows Host and every
882
+ // POSIX Host keep running, so neither says anything.
883
+ if (isTuiHostProcess() && !hostHasOwnConsole()) {
884
+ this.pushRow({ kind: 'system', text: t('boot.directHost') });
885
+ }
872
886
  for (const notice of config.launchNotices ?? []) {
873
887
  this.pushRow({ kind: notice.kind, text: notice.text });
874
888
  }
@@ -898,7 +912,7 @@ export class SshTui {
898
912
  process.stdin.setRawMode(true);
899
913
  process.stdin.resume();
900
914
  process.stdout.on('resize', this.onDirectResize);
901
- if (process.platform !== 'win32') {
915
+ if (usesSigwinch()) {
902
916
  process.on('SIGWINCH', this.onDirectResize);
903
917
  }
904
918
  process.stdin.prependListener('end', this.handleHangupStream);
@@ -912,7 +926,7 @@ export class SshTui {
912
926
  this.bootBackgroundTasks();
913
927
  return;
914
928
  }
915
- this.write(`${this.useAlternateScreen ? '\x1b[?1049h' : ''}\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?2004h\x1b[?25l`);
929
+ this.write(this.enterScreenSequence());
916
930
  this.render();
917
931
  this.updateTerminalTitle();
918
932
  void this.calibratePaintInterval().finally(() => {
@@ -923,11 +937,22 @@ export class SshTui {
923
937
  });
924
938
  this.bootBackgroundTasks();
925
939
  }
940
+ /**
941
+ * Take over the screen: alternate screen where it is safe, then the input
942
+ * modes the terminal actually supports, then hide the cursor.
943
+ *
944
+ * Joining the parts here (rather than at each call site) is what keeps
945
+ * `start`, a reattach and a teardown from drifting apart — the reattach path
946
+ * used to duplicate this string verbatim.
947
+ */
948
+ enterScreenSequence() {
949
+ return `${this.useAlternateScreen ? '\x1b[?1049h' : ''}${mouseEnableSequence(this.terminalCaps)}${bracketedPasteSequence(this.terminalCaps, true)}\x1b[?25l`;
950
+ }
926
951
  bindAgentEvents() {
927
952
  this.disposers.push(this.ctx.on('session/event', this.handleSessionEvent), listenHostEvent(this.ctx, 'agent/assistant-stream', this.handleAssistantStream), this.ctx.on('agent/status', this.handleStatus), this.ctx.on('agent/error', this.handleError), this.ctx.on('agent/disposed', this.handleDisposed), this.ctx.on('agent/inbox/claimed', this.handleInboxClaimed), this.ctx.on('agent/inbox/discarded', this.handleInboxDiscarded), this.ctx.on('agent/request', this.handleAgentRequest), this.ctx.on('subagent/start', this.handleSubagentStart), this.ctx.on('subagent/end', this.handleSubagentEnd), this.ctx.on('approval/request', this.handleApproval));
928
953
  const questions = this.ctx.get('userQuestions');
929
954
  if (questions !== undefined) {
930
- this.userQuestionDisposer = installUserQuestionAnswerer(this.ctx, questions, this.handleUserQuestions);
955
+ this.userQuestionDisposer = installUserQuestionAnswerer(this.ctx, this.handleUserQuestions);
931
956
  }
932
957
  }
933
958
  bootBackgroundTasks() {
@@ -1008,7 +1033,7 @@ export class SshTui {
1008
1033
  }
1009
1034
  }
1010
1035
  readSkippedUpdate() {
1011
- const raw = this.ctx.get('settings')?.get(UI_LOCALE_NAMESPACE);
1036
+ const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
1012
1037
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
1013
1038
  return undefined;
1014
1039
  const skip = raw.skipUpdate;
@@ -1021,14 +1046,14 @@ export class SshTui {
1021
1046
  const settings = this.ctx.get('settings');
1022
1047
  if (settings === undefined)
1023
1048
  return;
1024
- const raw = settings.get(UI_LOCALE_NAMESPACE);
1049
+ const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
1025
1050
  const previous = raw !== null && typeof raw === 'object' && !Array.isArray(raw)
1026
1051
  ? raw
1027
1052
  : {};
1028
1053
  await settings.replace(UI_LOCALE_NAMESPACE, { ...previous, ...patch });
1029
1054
  }
1030
1055
  readWorkspaceView() {
1031
- const raw = this.ctx.get('settings')?.get(UI_LOCALE_NAMESPACE);
1056
+ const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
1032
1057
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
1033
1058
  return 'detailed';
1034
1059
  return parseWorkspaceView(String(raw.view ?? '')) ?? 'detailed';
@@ -1037,7 +1062,7 @@ export class SshTui {
1037
1062
  const env = parseDisconnectPolicy(process.env.DSH_TUI_DISCONNECT ?? '');
1038
1063
  if (env !== undefined)
1039
1064
  return env;
1040
- const raw = this.ctx.get('settings')?.get(UI_LOCALE_NAMESPACE);
1065
+ const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
1041
1066
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
1042
1067
  return 'pause';
1043
1068
  return parseDisconnectPolicy(String(raw.disconnect ?? '')) ?? 'pause';
@@ -1046,7 +1071,7 @@ export class SshTui {
1046
1071
  const env = parseAutoApprovalMode(process.env.DSH_TUI_AUTO_APPROVAL ?? '');
1047
1072
  if (env !== undefined)
1048
1073
  return env;
1049
- const raw = this.ctx.get('settings')?.get(UI_LOCALE_NAMESPACE);
1074
+ const raw = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
1050
1075
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
1051
1076
  return 'off';
1052
1077
  return parseAutoApprovalMode(String(raw.autoApproval ?? '')) ?? 'off';
@@ -1086,7 +1111,7 @@ export class SshTui {
1086
1111
  const raw = Number.parseInt(process.env.DSH_TUI_IDLE_EXIT_MS ?? '', 10);
1087
1112
  if (Number.isFinite(raw) && raw >= 0)
1088
1113
  return raw;
1089
- const saved = this.ctx.get('settings')?.get(UI_LOCALE_NAMESPACE);
1114
+ const saved = readSettingsSection(this.ctx, UI_LOCALE_NAMESPACE);
1090
1115
  if (saved !== null && typeof saved === 'object' && !Array.isArray(saved)) {
1091
1116
  const value = saved.idleExit;
1092
1117
  if (typeof value === 'number' && Number.isFinite(value) && value >= 0)
@@ -1510,7 +1535,11 @@ export class SshTui {
1510
1535
  try {
1511
1536
  process.stdout.write('\x1b]0;\x07');
1512
1537
  process.stdout.write('\x1b[0m\x1b[2J\x1b[3J\x1b[H');
1513
- process.stdout.write(`\x1b[?1000l\x1b[?1002l\x1b[?1006l\x1b[?2004l\x1b[?25h${this.useAlternateScreen ? '\x1b[?1049l' : ''}`);
1538
+ // Leave the alternate screen unconditionally, the way every mouse mode is
1539
+ // disabled: `?1049l` on a terminal that never entered is ignored, while
1540
+ // *skipping* it after a reattach whose relay classified the terminal
1541
+ // differently leaves the user stuck in a screen they cannot scroll.
1542
+ process.stdout.write(`${mouseDisableSequence()}${bracketedPasteSequence(this.terminalCaps, false)}\x1b[?25h\x1b[?1049l`);
1514
1543
  }
1515
1544
  catch (error) {
1516
1545
  if (!isHangupErrno(error)) {
@@ -1611,10 +1640,7 @@ export class SshTui {
1611
1640
  }
1612
1641
  /** Routing facts for the doctor's self-consistency check. */
1613
1642
  doctorRouting() {
1614
- const settings = this.ctx.get('settings');
1615
- if (settings === undefined)
1616
- return undefined;
1617
- const section = settings.get(settingsNamespace('llm-pi-ai'));
1643
+ const section = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
1618
1644
  const providers = section?.providers ?? {};
1619
1645
  const provider = this.currentProvider();
1620
1646
  const models = Array.isArray(providers[provider]?.models) ? providers[provider]?.models ?? [] : [];
@@ -1640,6 +1666,7 @@ export class SshTui {
1640
1666
  roster: this.ctx.get('agentPresets') !== undefined,
1641
1667
  codeRuntime: this.hasHostService('codeRuntime'),
1642
1668
  },
1669
+ generation: hostSettingsGeneration(this.ctx),
1643
1670
  anchors: [process.argv[1], this.ctx.baseUrl],
1644
1671
  ...(routing === undefined ? {} : { routing }),
1645
1672
  });
@@ -1660,11 +1687,11 @@ export class SshTui {
1660
1687
  /** `/fix <row>`: repair one named roster row of the profile patch. */
1661
1688
  async runFixCommand(arg) {
1662
1689
  const name = arg.trim().toLowerCase();
1663
- const row = ROSTER_ROWS.find(candidate => candidate.id === name || candidate.name.toLowerCase() === name);
1690
+ const row = ALL_ROSTER_ROWS.find(candidate => candidate.id === name || candidate.name.toLowerCase() === name);
1664
1691
  if (row === undefined) {
1665
1692
  this.pushRow({
1666
1693
  kind: 'error',
1667
- text: t('doctor.fix.unknownRow', { row: arg.trim(), rows: ROSTER_ROWS.map(candidate => candidate.id).join(', ') }),
1694
+ text: t('doctor.fix.unknownRow', { row: arg.trim(), rows: ALL_ROSTER_ROWS.map(candidate => candidate.id).join(', ') }),
1668
1695
  });
1669
1696
  this.markDirty();
1670
1697
  return;
@@ -1683,7 +1710,7 @@ export class SshTui {
1683
1710
  const duplicates = facts.patch.readable ? planDuplicateRepair(text) : undefined;
1684
1711
  if (duplicates !== undefined)
1685
1712
  text = duplicates.text;
1686
- const roster = planRosterRepair(text, rows);
1713
+ const roster = planRosterRepair(text, rows, facts.generation ?? 'legacy');
1687
1714
  if (roster !== undefined)
1688
1715
  text = roster.text;
1689
1716
  const added = roster?.added ?? [];
@@ -2066,7 +2093,7 @@ export class SshTui {
2066
2093
  void this.onReattach?.();
2067
2094
  return;
2068
2095
  }
2069
- this.write(`${this.useAlternateScreen ? '\x1b[?1049h' : ''}\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?2004h\x1b[?25l`);
2096
+ this.write(this.enterScreenSequence());
2070
2097
  this.forceFullPaint = true;
2071
2098
  this.dirty = true;
2072
2099
  // The user is back: say so, with how many times this Host has been
@@ -2375,7 +2402,7 @@ export class SshTui {
2375
2402
  const note = planDockNote(plan);
2376
2403
  lines.push(this.styleLine('plan-dock', padToWidth(` ${note}`, width)));
2377
2404
  if (plan.planMarkdown !== undefined && plan.planMarkdown !== '') {
2378
- const markdown = renderMarkdownLines(plan.planMarkdown, inner, this.color);
2405
+ const markdown = renderMarkdownLines(plan.planMarkdown, inner, this.color, this.terminalCaps.osc8);
2379
2406
  const budget = Math.max(4, Math.min(12, markdown.length));
2380
2407
  for (const line of markdown.slice(0, budget)) {
2381
2408
  lines.push(`${clipAnsiToWidth(` ${line}`, width)}\x1b[0m`);
@@ -2944,7 +2971,7 @@ export class SshTui {
2944
2971
  };
2945
2972
  const pushRow = (kind, text, ref) => {
2946
2973
  if (kind === 'assistant') {
2947
- for (const line of renderMarkdownLines(text, width, this.color)) {
2974
+ for (const line of renderMarkdownLines(text, width, this.color, this.terminalCaps.osc8)) {
2948
2975
  addDisplay(line, ref);
2949
2976
  }
2950
2977
  return;
@@ -3087,7 +3114,7 @@ export class SshTui {
3087
3114
  if (row.expanded) {
3088
3115
  addDisplay(this.styleLine('plan-dock', ` ${planDockNote({ ...row, active: false, pending: false })}`), row);
3089
3116
  if (row.planMarkdown !== undefined && row.planMarkdown !== '') {
3090
- for (const line of renderMarkdownLines(row.planMarkdown, Math.max(1, width - 2), this.color).slice(0, 8)) {
3117
+ for (const line of renderMarkdownLines(row.planMarkdown, Math.max(1, width - 2), this.color, this.terminalCaps.osc8).slice(0, 8)) {
3091
3118
  addDisplay(` ${line}`, row);
3092
3119
  }
3093
3120
  }
@@ -3125,7 +3152,7 @@ export class SshTui {
3125
3152
  }
3126
3153
  if (row.detail !== undefined && row.detail !== '') {
3127
3154
  if (row.intent === 'plan-review') {
3128
- for (const line of renderMarkdownLines(row.detail, Math.max(1, width - 2), this.color)) {
3155
+ for (const line of renderMarkdownLines(row.detail, Math.max(1, width - 2), this.color, this.terminalCaps.osc8)) {
3129
3156
  addDisplay(` ${line}`, row);
3130
3157
  }
3131
3158
  }
@@ -3408,7 +3435,7 @@ export class SshTui {
3408
3435
  addDialog(t('dialog.planReview', { index: d.index + 1, total: d.total }) + (d.question.header === undefined ? '' : ` · ${d.question.header}`));
3409
3436
  addDialog(d.question.question);
3410
3437
  if (d.question.detail !== undefined && d.question.detail !== '') {
3411
- for (const line of renderMarkdownLines(d.question.detail, Math.max(1, width - 2), this.color).slice(0, 16)) {
3438
+ for (const line of renderMarkdownLines(d.question.detail, Math.max(1, width - 2), this.color, this.terminalCaps.osc8).slice(0, 16)) {
3412
3439
  dialogLines.push(this.styleLine('assistant', line));
3413
3440
  }
3414
3441
  }
@@ -3670,7 +3697,7 @@ export class SshTui {
3670
3697
  // the session counters go before the operational signals. Quota and context
3671
3698
  // pressure are deliberately not here — they are on the identity line.
3672
3699
  const strip = [];
3673
- const health = footerHealthChip(this.rosterMissing, this.color);
3700
+ const health = footerHealthChip(this.rosterMissing, this.color, this.settingsGeneration === 'forms' ? 'agent-plane' : 'roster');
3674
3701
  // Muted like the identity line, accent excepted: the pre-strip footer
3675
3702
  // painted the counters dim and only the ⚠'s own glyph yellow.
3676
3703
  if (health !== undefined)
@@ -3828,6 +3855,10 @@ export class SshTui {
3828
3855
  updateTerminalTitle() {
3829
3856
  if (this.exiting || this.lineMode)
3830
3857
  return;
3858
+ // A console that has no title to set (the Linux virtual console) would take
3859
+ // the escape as noise; the matrix says so, so honour it.
3860
+ if (!this.terminalCaps.title)
3861
+ return;
3831
3862
  const now = Date.now();
3832
3863
  // Completion wins over a still-running agent status: the turn/end event
3833
3864
  // lands before agent/status flips to idle, and the title must not stay
@@ -3936,13 +3967,7 @@ export class SshTui {
3936
3967
  if (service === undefined) {
3937
3968
  this.pushRow({
3938
3969
  kind: 'error',
3939
- text: [
3940
- t('preset.missingService'),
3941
- t('mode.missingServiceHint', {
3942
- profile: profileFromArgv(),
3943
- patch: rosterPatchPath(resolveDshHome(), profileFromArgv()),
3944
- }),
3945
- ].join('\n'),
3970
+ text: this.missingPresetService(t('preset.missingService')),
3946
3971
  });
3947
3972
  this.markDirty();
3948
3973
  return;
@@ -4122,9 +4147,11 @@ export class SshTui {
4122
4147
  }
4123
4148
  const lines = [t('preset.showTitle', { id: preset.id, name: preset.name ?? preset.id })];
4124
4149
  lines.push(t('preset.showMeta', {
4125
- trust: preset.trust === 'user' ? t('preset.trustUser') : t('preset.trustSystem'),
4150
+ trust: preset.trust === 'user'
4151
+ ? t('preset.trustUser')
4152
+ : preset.trust === undefined ? t('preset.trustManaged') : t('preset.trustSystem'),
4126
4153
  order: preset.order === undefined ? '—' : String(preset.order),
4127
- directory: presetDirectory(preset),
4154
+ directory: presetDirectory(preset) ?? t('preset.directoryManaged'),
4128
4155
  }));
4129
4156
  if (preset.description !== undefined)
4130
4157
  lines.push(t('preset.showDescription', { description: preset.description }));
@@ -4189,7 +4216,7 @@ export class SshTui {
4189
4216
  from: plan.from,
4190
4217
  id: plan.id,
4191
4218
  name: plan.name === undefined ? '' : ` · ${plan.name}`,
4192
- directory: presetDirectory(presets.find(candidate => candidate.id === plan.id) ?? presets[0]),
4219
+ directory: presetDirectory(presets.find(candidate => candidate.id === plan.id) ?? presets[0]) ?? '',
4193
4220
  });
4194
4221
  });
4195
4222
  }
@@ -4212,7 +4239,8 @@ export class SshTui {
4212
4239
  }
4213
4240
  // Merge with what the preset publishes now, so editing one field never
4214
4241
  // drops the other and `order` survives.
4215
- const current = await readPresetMetadata(presetDirectory(preset));
4242
+ const directory = presetDirectory(preset);
4243
+ const current = directory === undefined ? {} : await readPresetMetadata(directory);
4216
4244
  const plan = planMetadata({ preset, current, patch });
4217
4245
  if (isRefusal(plan)) {
4218
4246
  this.pushPresetRefusal(plan);
@@ -4255,7 +4283,7 @@ export class SshTui {
4255
4283
  return;
4256
4284
  }
4257
4285
  const answer = await new Promise(resolve => {
4258
- this.openConfirm(t('preset.deleteConfirm', { id: plan.id }), t('preset.deleteHint', { directory: presetDirectory(preset) }), resolve);
4286
+ this.openConfirm(t('preset.deleteConfirm', { id: plan.id }), t('preset.deleteHint', { directory: presetDirectory(preset) ?? t('preset.directoryManaged') }), resolve);
4259
4287
  });
4260
4288
  if (answer !== 'y') {
4261
4289
  this.pushRow({ kind: 'system', text: t('preset.deleteCancelled') });
@@ -4384,11 +4412,11 @@ export class SshTui {
4384
4412
  return `\x1b[7m${styled.replaceAll('\x1b[0m', '\x1b[0m\x1b[7m')}\x1b[27m`;
4385
4413
  }
4386
4414
  /**
4387
- * Fold one live or durable stream chunk into the in-progress assistant
4388
- * row. 0.1.2 hosts append `assistant/chunk`; 0.1.5 emits the same chunk
4389
- * on `agent/assistant-stream` and never writes it to the log.
4415
+ * Fold one live stream chunk into the in-progress assistant row. Hosts from
4416
+ * 0.1.5 on emit the chunk on `agent/assistant-stream` and never write it to
4417
+ * the log.
4390
4418
  */
4391
- applyStreamChunk(streamed, statsOnly = false) {
4419
+ applyStreamChunk(streamed) {
4392
4420
  const { chunk } = streamed;
4393
4421
  if (isTokenDeltaChunk(chunk)) {
4394
4422
  this.statsTracker.noteFirstToken(streamed.turn, streamed.step, streamed.time);
@@ -4396,8 +4424,6 @@ export class SshTui {
4396
4424
  if (chunk.type === 'usage' && chunk.usage !== undefined && streamed.stepKnown) {
4397
4425
  this.statsTracker.recordUsage(streamed.turn, streamed.step, chunk.usage);
4398
4426
  }
4399
- if (statsOnly)
4400
- return;
4401
4427
  if (chunk.type === 'text-delta') {
4402
4428
  this.streaming ??= { text: '', reasoning: '' };
4403
4429
  this.streaming.text += chunk.text ?? '';
@@ -4414,9 +4440,9 @@ export class SshTui {
4414
4440
  }
4415
4441
  }
4416
4442
  /**
4417
- * 0.1.5 live tokens arrive as process-local `agent/assistant-stream`
4418
- * frames (start / chunk / end). Chunk frames carry the same
4419
- * `StreamChunk` the 0.1.2 log used to store as `assistant/chunk`.
4443
+ * 0.1.5 and 0.1.7 live tokens arrive as process-local
4444
+ * `agent/assistant-stream` frames (start / chunk / end). Chunk frames carry
4445
+ * the same `StreamChunk` the settled `assistant/message` embeds in `stream`.
4420
4446
  */
4421
4447
  handleAssistantStream = (...args) => {
4422
4448
  const payload = args[0];
@@ -4505,23 +4531,8 @@ export class SshTui {
4505
4531
  return;
4506
4532
  }
4507
4533
  this.lastActivity = Date.now();
4508
- if (this.replaying && isAssistantStreamEvent(event)) {
4509
- // Replay skips the in-progress row, but durable chunks still carry the
4510
- // timing the footer needs: a resumed session must keep TTFT and tok/s.
4511
- const streamed = streamChunkOf(event);
4512
- if (streamed !== undefined)
4513
- this.applyStreamChunk(streamed, true);
4514
- return;
4515
- }
4516
4534
  if (!this.replaying)
4517
4535
  this.refreshContextPressure();
4518
- const eventType = sessionEventType(event);
4519
- if (eventType === 'assistant/chunk') {
4520
- const streamed = streamChunkOf(event);
4521
- if (streamed !== undefined)
4522
- this.applyStreamChunk(streamed);
4523
- return;
4524
- }
4525
4536
  switch (event.type) {
4526
4537
  case 'user/message': {
4527
4538
  const text = event.data.content
@@ -4579,8 +4590,7 @@ export class SshTui {
4579
4590
  .join('');
4580
4591
  // The settlement's own packed stream is authoritative: a retried step
4581
4592
  // keeps the failed attempt's first token in the live latch, and spanning
4582
- // both attempts reported a rate ~20x off. 0.1.2 has no `stream` and still
4583
- // relies on the replayed `assistant/chunk` events (or the live latch).
4593
+ // both attempts reported a rate ~20x off.
4584
4594
  this.statsTracker.settleMessage({
4585
4595
  turn: event.data.turn,
4586
4596
  step: event.data.step,
@@ -4694,7 +4704,7 @@ export class SshTui {
4694
4704
  this.markDirty();
4695
4705
  break;
4696
4706
  }
4697
- const failed = event.data.error !== undefined || event.data.message.content[0]?.isError === true;
4707
+ const failed = event.data.error !== undefined || toolResultFailed(event.data.message);
4698
4708
  spawnCard.status = failed ? 'error' : 'ok';
4699
4709
  spawnCard.endedAt = typeof event.time === 'number' ? event.time : Date.now();
4700
4710
  if (output !== '') {
@@ -4725,7 +4735,7 @@ export class SshTui {
4725
4735
  row.output = output;
4726
4736
  }
4727
4737
  const failed = event.data.error !== undefined
4728
- || event.data.message.content[0]?.isError === true
4738
+ || toolResultFailed(event.data.message)
4729
4739
  || (isShell && ((row.exitCode !== undefined && row.exitCode !== 0) || row.signal !== undefined));
4730
4740
  row.status = failed ? 'error' : 'ok';
4731
4741
  if (READ_TOOL_NAMES.has(row.name)) {
@@ -5418,8 +5428,6 @@ export class SshTui {
5418
5428
  const row = this.findSubagentRow(String(sessionId));
5419
5429
  if (row === undefined)
5420
5430
  return;
5421
- if (sessionEventType(event) === 'assistant/chunk')
5422
- return;
5423
5431
  switch (event.type) {
5424
5432
  case 'user/message': {
5425
5433
  const text = collectText(event.data.content);
@@ -5450,7 +5458,7 @@ export class SshTui {
5450
5458
  break;
5451
5459
  }
5452
5460
  case 'tool/result': {
5453
- const ok = event.data.error === undefined && event.data.message.content[0]?.isError !== true;
5461
+ const ok = event.data.error === undefined && !toolResultFailed(event.data.message);
5454
5462
  const callId = String(event.data.message.source.callId);
5455
5463
  const sourceName = event.data.message.source.name;
5456
5464
  const recorded = this.toolCallNames.get(callId)
@@ -6209,7 +6217,7 @@ export class SshTui {
6209
6217
  piAiProviderProfile(provider) {
6210
6218
  if (provider === 'deepseek-official')
6211
6219
  return undefined;
6212
- const section = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
6220
+ const section = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
6213
6221
  return section?.providers?.[provider];
6214
6222
  }
6215
6223
  /**
@@ -6428,7 +6436,7 @@ export class SshTui {
6428
6436
  * picker costs one listing instead of one per sibling.
6429
6437
  */
6430
6438
  async discoverGatewayListing(provider, signal) {
6431
- const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
6439
+ const llmPiAi = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
6432
6440
  const profile = this.piAiProviderProfile(provider);
6433
6441
  const source = openCodeSourceFor(provider, llmPiAi);
6434
6442
  const baseURL = typeof profile?.baseURL === 'string' && profile.baseURL.trim() !== ''
@@ -6821,7 +6829,7 @@ export class SshTui {
6821
6829
  }
6822
6830
  /** Persist a provider/model/effort choice and keep the subagent on the same family. */
6823
6831
  rememberedRoute(provider) {
6824
- const section = this.ctx.get('settings')?.get(ROUTE_MEMORY_NS);
6832
+ const section = readSettingsSection(this.ctx, ROUTE_MEMORY_NS);
6825
6833
  const memory = section !== null && typeof section === 'object' && !Array.isArray(section)
6826
6834
  ? parseRouteMemory(section.providers)
6827
6835
  : {};
@@ -6870,7 +6878,7 @@ export class SshTui {
6870
6878
  const settings = this.ctx.get('settings');
6871
6879
  if (settings === undefined)
6872
6880
  return;
6873
- const section = settings.get(ROUTE_MEMORY_NS);
6881
+ const section = readSettingsSection(this.ctx, ROUTE_MEMORY_NS);
6874
6882
  const memory = section !== null && typeof section === 'object' && !Array.isArray(section)
6875
6883
  ? parseRouteMemory(section.providers)
6876
6884
  : {};
@@ -6986,7 +6994,7 @@ export class SshTui {
6986
6994
  }
6987
6995
  if (settings !== undefined) {
6988
6996
  try {
6989
- await settings.replace(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, {
6997
+ await settings.replace(AGENT_DEFAULT_MODEL_NS, {
6990
6998
  provider: next.provider,
6991
6999
  model: next.model,
6992
7000
  ...(next.reasoningEffort === undefined ? {} : { reasoningEffort: String(next.reasoningEffort) }),
@@ -7586,7 +7594,7 @@ export class SshTui {
7586
7594
  }
7587
7595
  /** /mode: pick an agent preset (standard / minimal / ptc / cordis / routing-suite / ...). */
7588
7596
  async runModeCommand(arg = '') {
7589
- const agentPresets = this.ctx.get('agentPresets');
7597
+ const agentPresets = this.presetService();
7590
7598
  const direct = arg.trim().toLowerCase();
7591
7599
  if (direct === 'fix' || direct === 'repair') {
7592
7600
  await this.repairRoster();
@@ -7601,13 +7609,7 @@ export class SshTui {
7601
7609
  const profile = profileFromArgv();
7602
7610
  this.pushRow({
7603
7611
  kind: 'error',
7604
- text: [
7605
- t('mode.missingService'),
7606
- t('mode.missingServiceHint', {
7607
- profile,
7608
- patch: rosterPatchPath(resolveDshHome(), profile),
7609
- }),
7610
- ].join('\n'),
7612
+ text: this.missingPresetService(t('mode.missingService')),
7611
7613
  });
7612
7614
  this.markDirty();
7613
7615
  return;
@@ -7667,6 +7669,13 @@ export class SshTui {
7667
7669
  const selectedName = option.label;
7668
7670
  const hasWork = sessionEvents(this.agent.session).some(event => event.type === 'turn/start');
7669
7671
  if (!hasWork) {
7672
+ // Feature-detected: a host whose registry predates `recompose` still
7673
+ // lists presets, and switching then needs a restarted session.
7674
+ if (typeof agentPresets.recompose !== 'function') {
7675
+ this.pushRow({ kind: 'error', text: t('mode.noRecompose', { id: selected.id }) });
7676
+ this.markDirty();
7677
+ return;
7678
+ }
7670
7679
  await agentPresets.recompose(this.agent.ctx, selected.id);
7671
7680
  this.presetId = selected.id;
7672
7681
  this.presetName = selectedName;
@@ -7690,13 +7699,40 @@ export class SshTui {
7690
7699
  */
7691
7700
  /** Re-read the roster fact this process was composed with (cheap, no IO). */
7692
7701
  refreshRosterHealth() {
7693
- this.rosterMissing = this.ctx.get('agentPresets') === undefined;
7702
+ // 0.1.7 has no roster to be missing: what a terminal profile owns there is
7703
+ // the agent-plane rows, so the chip reports those instead.
7704
+ this.rosterMissing = this.settingsGeneration === 'forms'
7705
+ ? !formsRowsDeclared(resolveDshHome(), profileFromArgv())
7706
+ : this.ctx.get('agentPresets') === undefined;
7707
+ }
7708
+ /** Which settings protocol this host speaks; decides what "missing" means. */
7709
+ get settingsGeneration() {
7710
+ return hostSettingsGeneration(this.ctx);
7711
+ }
7712
+ /**
7713
+ * The two lines `/mode` and `/preset` print when the preset service is absent.
7714
+ *
7715
+ * The reason differs by host line: 0.1.5 lost a roster the profile can mount,
7716
+ * while 0.1.7 composes the agent process-wide and has no terminal presets at
7717
+ * all — the repair there is the agent-plane rows, not a roster.
7718
+ */
7719
+ missingPresetService(first) {
7720
+ const profile = profileFromArgv();
7721
+ const patch = rosterPatchPath(resolveDshHome(), profile);
7722
+ return this.settingsGeneration === 'forms'
7723
+ ? [first, t('mode.formsHost'), t('mode.formsHint', { profile, patch })].join('\n')
7724
+ : [first, t('mode.missingServiceHint', { profile, patch })].join('\n');
7694
7725
  }
7695
7726
  async repairRoster() {
7696
7727
  const profile = profileFromArgv();
7697
7728
  const patch = rosterPatchPath(resolveDshHome(), profile);
7698
7729
  try {
7699
- const result = await ensureRosterRows(resolveDshHome(), profile);
7730
+ // Which rows are "the rows" is a host-line fact: 0.1.7 composes the agent
7731
+ // process-wide and owns the agent-plane rows instead of the roster. Both
7732
+ // the list and the header come from the same generation `/doctor` uses,
7733
+ // or this writes rows the running host cannot resolve.
7734
+ const generation = this.settingsGeneration;
7735
+ const result = await ensureRosterRows(resolveDshHome(), profile, rosterRows(generation), generation);
7700
7736
  this.refreshRosterHealth();
7701
7737
  this.pushRow({
7702
7738
  kind: 'system',
@@ -7817,7 +7853,7 @@ export class SshTui {
7817
7853
  return;
7818
7854
  }
7819
7855
  const provider = this.currentProviderId();
7820
- const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
7856
+ const llmPiAi = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
7821
7857
  const source = openCodeSourceFor(provider, llmPiAi);
7822
7858
  if (source?.flavor === 'zen') {
7823
7859
  this.pushRow({ kind: 'system', text: this.zenUsageText(source) });
@@ -7879,7 +7915,7 @@ export class SshTui {
7879
7915
  * neither gets nothing.
7880
7916
  */
7881
7917
  hasQuotaSurface(provider) {
7882
- const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
7918
+ const llmPiAi = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
7883
7919
  return providerHasQuotaSurface(provider, llmPiAi);
7884
7920
  }
7885
7921
  async refreshQuota(options) {
@@ -7938,7 +7974,7 @@ export class SshTui {
7938
7974
  const apiKey = await this.resolveCredential('DEEPSEEK_API_KEY');
7939
7975
  if (apiKey === undefined)
7940
7976
  throw new Error(t('usage.noDeepseekKey'));
7941
- const section = this.ctx.get('settings')?.get(settingsNamespace('llm-deepseek'));
7977
+ const section = readSettingsSection(this.ctx, settingsNamespace('llm-deepseek'));
7942
7978
  const baseURL = typeof section?.baseURL === 'string' && section.baseURL.trim() !== ''
7943
7979
  ? section.baseURL.trim()
7944
7980
  : (process.env.DEEPSEEK_BASE_URL?.trim() || DEEPSEEK_PUBLIC_BASE_URL);
@@ -8007,7 +8043,7 @@ export class SshTui {
8007
8043
  return parseSuperGrokBilling(payload);
8008
8044
  }
8009
8045
  }
8010
- const llmPiAi = this.ctx.get('settings')?.get(settingsNamespace('llm-pi-ai'));
8046
+ const llmPiAi = readSettingsSection(this.ctx, settingsNamespace('llm-pi-ai'));
8011
8047
  const commandCode = commandCodeSourceFor(provider, llmPiAi);
8012
8048
  if (commandCode !== null)
8013
8049
  return this.fetchCommandCodeQuota(commandCode);
@@ -9374,6 +9410,10 @@ export class SshTui {
9374
9410
  const shadowed = shadowing !== undefined && shadowing !== '';
9375
9411
  if (credentials !== undefined && !shadowed) {
9376
9412
  await credentials.set(credentialRef(envRef), key);
9413
+ // The store writes it; we are the reason it exists. On Windows that write
9414
+ // ignored any mode the store passed, so the file that holds every API key
9415
+ // gets its ACL tightened here.
9416
+ await restrictPathToUser(join(dshHomeDir(), '.credentials.yaml'), { mode: 0o600 });
9377
9417
  this.pushRow({ kind: 'system', text: t('onboard.credSaved', { env: envRef, path: displayDshPath('.credentials.yaml') }) });
9378
9418
  return;
9379
9419
  }
@@ -9384,13 +9424,13 @@ export class SshTui {
9384
9424
  ? IS_WINDOWS
9385
9425
  ? t('onboard.envShadowWin', { env: envRef })
9386
9426
  : t('onboard.envShadowUnix', { env: envRef })
9387
- : t('onboard.credMissing', { path: displayDshPath(IS_WINDOWS ? 'env.cmd' : 'env.sh') }),
9427
+ : t('onboard.credMissing', { path: displayDshPath(envFileName()) }),
9388
9428
  });
9389
9429
  }
9390
9430
  /** Write launch-environment overrides so they beat system-injected variables. */
9391
9431
  async writeLaunchEnv(entries) {
9392
9432
  const home = dshHomeDir();
9393
- const file = join(home, IS_WINDOWS ? 'env.cmd' : 'env.sh');
9433
+ const file = join(home, envFileName());
9394
9434
  await mkdir(home, { recursive: true, mode: 0o700 });
9395
9435
  if (IS_WINDOWS) {
9396
9436
  let previous = '';
@@ -9414,6 +9454,10 @@ export class SshTui {
9414
9454
  const additions = Object.entries(entries).map(([name, value]) => `set "${name}=${value.replaceAll('"', '')}"`);
9415
9455
  const lines = ['@echo off', 'rem Generated by dsh-ssh-tui onboarding.', ...preserved, ...additions];
9416
9456
  await writeFile(file, `${lines.join('\r\n')}\r\n`, { mode: 0o600 });
9457
+ // This file holds API keys. `mode` covers POSIX; on Windows the same
9458
+ // intent has to be an ACL or the keys sit under whatever the parent
9459
+ // directory allowed.
9460
+ await restrictPathToUser(file, { mode: 0o600 });
9417
9461
  // Persist for future processes; best-effort, env.cmd remains as a manual fallback.
9418
9462
  await Promise.all(Object.entries(entries).map(([name, value]) => this.setWindowsEnv(name, value))).catch(() => { });
9419
9463
  return;
@@ -9437,6 +9481,7 @@ export class SshTui {
9437
9481
  });
9438
9482
  const additions = Object.entries(entries).map(([name, value]) => `export ${name}=${quote(value)}`);
9439
9483
  await writeFile(file, `${['# Generated by dsh-ssh-tui onboarding.', ...preserved, ...additions].join('\n')}\n`, { mode: 0o600 });
9484
+ await restrictPathToUser(file, { mode: 0o600 });
9440
9485
  await this.ensurePosixEnvHook();
9441
9486
  }
9442
9487
  /** Persist one variable into the Windows user environment (best-effort). */
@@ -9699,6 +9744,16 @@ export class SshTui {
9699
9744
  this.leaveHistoryBrowse();
9700
9745
  this.write(osc52Clipboard(text));
9701
9746
  this.pushRow({ kind: 'system', text: notice });
9747
+ // The write is a request the terminal may ignore: VTE (GNOME/XFCE/…) has
9748
+ // never implemented OSC 52, conhost and screen cannot, Konsole only from
9749
+ // 24.12, and xterm/tmux need configuration. The write still goes out — it is
9750
+ // how the terminals that *do* honour it work — but where the table does not
9751
+ // promise it the user is told once per session rather than at every copy, or
9752
+ // not at all until they paste into an empty clipboard.
9753
+ if (!this.terminalCaps.osc52 && !this.osc52HintShown) {
9754
+ this.osc52HintShown = true;
9755
+ this.pushRow({ kind: 'system', text: t('copy.osc52Hint', { terminal: this.terminalCaps.label }) });
9756
+ }
9702
9757
  this.markDirty();
9703
9758
  }
9704
9759
  copyFocusedCard(arg = '') {
@@ -10388,7 +10443,16 @@ export function mountTui(ctx, config) {
10388
10443
  else
10389
10444
  process.exit(1);
10390
10445
  };
10391
- const disposeCreated = ctx.on('agent/created', ({ agent }) => start(agent));
10446
+ // The explicit `return undefined` is deliberate, not noise: 0.1.6-alpha.2 turns
10447
+ // `agent/created` into a serial event whose handler must return
10448
+ // `undefined | Promise<undefined>`, and a bare `void` body is *not* assignable
10449
+ // to that (verified by compiling this file against the published 0.1.6-alpha.2
10450
+ // types: TS2322/TS2345). Returning `undefined` satisfies the whole 0.1.5 line
10451
+ // too, so the handler is ready for the next line without a compat branch.
10452
+ const disposeCreated = ctx.on('agent/created', ({ agent }) => {
10453
+ start(agent);
10454
+ return undefined;
10455
+ });
10392
10456
  const disposeFailure = ctx.on('agent-loop/config-start-failed', ({ sessionId: failedSessionId, error }) => fail(failedSessionId, error));
10393
10457
  const stopWaiting = () => {
10394
10458
  disposeCreated();