dsh-ssh-tui 0.8.4-rc.1 → 0.8.4

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.
package/lib/tui.js CHANGED
@@ -48,7 +48,7 @@ import { resolveTheme, setActiveTheme, themeByName, themeEmphasisToken, themeExt
48
48
  import { presetLabel, profileFromArgv } from './preset-label.js';
49
49
  import { flattenGroups, groupPresets, optionMatches } from './preset-picker.js';
50
50
  import { isRefusal, planCopy, planDelete, planMetadata, presetDirectory, } from './preset-authoring.js';
51
- import { ensureRosterRows, planDuplicateRepair, planRosterRepair, formsRowsDeclared, rosterPatchPath, rosterRows, writePatchWithBackup, ALL_ROSTER_ROWS, } from './preset-rows.js';
51
+ import { ensureRosterRows, planDuplicateRepair, planRosterRepair, rosterPatchPath, rosterRows, writePatchWithBackup, ALL_ROSTER_ROWS, } from './preset-rows.js';
52
52
  import { SessionStatsTracker, sessionTokenTotal, statsRowOf, } from './stats.js';
53
53
  import { QUESTION_OPTION_KEYS, applyQuestionFilter, backspaceQuestionFilter, clearQuestionFilter, typeQuestionFilter, visibleQuestionIndexes, confirmAnswer, inspectClosesOn, moveQuestionCursor, optionsLength, questionOptionIndex, initialQuestionSelection, questionOptionMarker, questionSubmit, selectQuestionOptionByKey, windowInteractionLines, PICKER_ROLE, interactionRole, stallsTask, surfacePriority, isSurfaceDialog, } from './dialogs.js';
54
54
  import { commandSuggestions, localizedCommands } from './commands.js';
@@ -2241,6 +2241,7 @@ export class SshTui {
2241
2241
  const credentials = this.ctx.get('credentials');
2242
2242
  const provider = this.currentProviderId();
2243
2243
  if (providerUsesLocalOAuth(provider)) {
2244
+ await this.repairAgentPlaneAtBoot();
2244
2245
  // Workspace feedback *about* setup — not the wizard's own surface, which is the
2245
2246
  // setup Screen (B2.6 §12). These rows are the command plane's.
2246
2247
  this.pushRow(represent('command-status', {
@@ -2276,6 +2277,7 @@ export class SshTui {
2276
2277
  }
2277
2278
  if (envKey !== undefined && envKey !== '') {
2278
2279
  if (stored || existsSync(DSH_ENV_FILE) || this.resume) {
2280
+ await this.repairAgentPlaneAtBoot();
2279
2281
  this.pushRow(represent('command-status', {
2280
2282
  kind: 'system',
2281
2283
  text: t('onboard.envInUse', { env: envRef }),
@@ -2301,8 +2303,10 @@ export class SshTui {
2301
2303
  // got an empty workspace, a provider that cannot answer, and no hint that
2302
2304
  // `/setup` exists. The launch flag is not evidence of configuration; the
2303
2305
  // credential store is, and that is what `stored` reads.
2304
- if (stored)
2306
+ if (stored) {
2307
+ await this.repairAgentPlaneAtBoot();
2305
2308
  return;
2309
+ }
2306
2310
  this.pushRow(represent('command-status', { kind: 'system', text: t('onboard.needSetup') }));
2307
2311
  await this.runOnboarding();
2308
2312
  }
@@ -2519,10 +2523,29 @@ export class SshTui {
2519
2523
  const models = Array.isArray(providers[provider]?.models) ? providers[provider]?.models ?? [] : [];
2520
2524
  const model = this.agent.options.model ?? this.selectionRef?.current?.model;
2521
2525
  const sub = this.subagentSelection.current;
2526
+ // The official DeepSeek adapter is registered through `ctx.llm`, not through
2527
+ // `llm-pi-ai`; the adapter directory is the same source `src/index.ts` uses
2528
+ // for route availability. Keep the configured list too: a pi-ai gateway may
2529
+ // be configured before its adapter has finished registering. Doctor is
2530
+ // best-effort, so an unavailable directory falls back to that list rather
2531
+ // than failing the report.
2532
+ let adapterRoutes = [];
2533
+ const llm = this.ctx.get('llm');
2534
+ if (llm !== undefined) {
2535
+ try {
2536
+ adapterRoutes = llm.listProviders()
2537
+ .map(entry => entry.id.trim())
2538
+ .filter(id => id !== '');
2539
+ }
2540
+ catch {
2541
+ // Keep the configured set; `/doctor` must still render what it knows.
2542
+ }
2543
+ }
2544
+ const routes = [...new Set([...Object.keys(providers), ...adapterRoutes])];
2522
2545
  return {
2523
2546
  provider,
2524
2547
  ...(model === undefined ? {} : { model }),
2525
- routes: Object.keys(providers),
2548
+ routes,
2526
2549
  routeModels: models.map(modelEntryId).filter((id) => id !== undefined),
2527
2550
  ...(sub.provider === undefined ? {} : { subProvider: this.displayProviderId(sub.provider) }),
2528
2551
  ...(sub.model === undefined ? {} : { subModel: sub.model }),
@@ -2531,6 +2554,7 @@ export class SshTui {
2531
2554
  /** One collected `/doctor` snapshot, from this process's own services. */
2532
2555
  async collectDoctorFacts(profile) {
2533
2556
  const routing = this.doctorRouting();
2557
+ const tools = this.agentPlaneTools();
2534
2558
  return collectDoctor({
2535
2559
  profile,
2536
2560
  dshHome: resolveDshHome(),
@@ -2538,6 +2562,8 @@ export class SshTui {
2538
2562
  services: {
2539
2563
  roster: this.ctx.get('agentPresets') !== undefined,
2540
2564
  codeRuntime: this.hasHostService('codeRuntime'),
2565
+ askUserTool: tools.askUser,
2566
+ presentTool: tools.present,
2541
2567
  },
2542
2568
  generation: hostSettingsGeneration(this.ctx),
2543
2569
  anchors: [process.argv[1], this.ctx.baseUrl],
@@ -2620,7 +2646,7 @@ export class SshTui {
2620
2646
  backup = await writePatchWithBackup(facts.patchPath, text);
2621
2647
  }
2622
2648
  else {
2623
- await ensureRosterRows(facts.dshHome, facts.profile, rows);
2649
+ await ensureRosterRows(facts.dshHome, facts.profile, rows, facts.generation ?? 'legacy');
2624
2650
  }
2625
2651
  const done = [
2626
2652
  t('doctor.fix.applied', {
@@ -8391,6 +8417,13 @@ export class SshTui {
8391
8417
  const options = {
8392
8418
  provider,
8393
8419
  model,
8420
+ // The reviewer is a direct LLM call outside the Agent loop; the loop normally
8421
+ // supplies this identity. OpenCode requires a per-conversation session
8422
+ // signal: pi-ai's built-in provider turns this into `x-opencode-session`,
8423
+ // while the configured openai-responses route sends `session_id`. Other
8424
+ // providers may use it for their own affinity/cache routing. It must be the
8425
+ // Session that actually asked for approval, never a shared or random id.
8426
+ sessionId: request.agent.session.id,
8394
8427
  messages: [createUserMessage({
8395
8428
  content: [{ type: 'text', text: buildReviewUserMessage({
8396
8429
  userText,
@@ -10922,18 +10955,91 @@ export class SshTui {
10922
10955
  this.markDirty();
10923
10956
  }
10924
10957
  /**
10925
- * `/mode fix`: write the roster row into this profile's user patch layer.
10958
+ * The two tools a 0.1.7 terminal profile mounts itself, read from the running
10959
+ * composition. The profile patch is only the instruction; this is the answer
10960
+ * to whether the instruction actually produced a tool.
10961
+ */
10962
+ agentPlaneTools() {
10963
+ const tools = this.ctx.get('tools');
10964
+ return {
10965
+ askUser: tools?.get('ask_user_question') !== undefined,
10966
+ present: tools?.get('present') !== undefined,
10967
+ };
10968
+ }
10969
+ /** Whether this process's agent plane has both tools. */
10970
+ agentPlaneReady() {
10971
+ const tools = this.agentPlaneTools();
10972
+ return tools.askUser && tools.present;
10973
+ }
10974
+ /**
10975
+ * Repair an already-configured profile at boot and say a restart is needed.
10926
10976
  *
10927
- * The write only takes effect on the next launch — the loader composes the
10928
- * patch tree once at boot — so the report says so instead of pretending the
10929
- * running session gained a roster.
10977
+ * A first run deliberately does not call this: its wizard save is the point
10978
+ * where settings and credentials exist, and `onboardDone` repairs immediately
10979
+ * after that write. This method serves the old/partial profile upgrade path,
10980
+ * where there is no wizard to wait for.
10930
10981
  */
10982
+ async repairAgentPlaneAtBoot() {
10983
+ const profile = profileFromArgv();
10984
+ try {
10985
+ const repaired = await this.ensureProfileAgentPlane(profile);
10986
+ if (repaired === 'written') {
10987
+ this.pushRow(represent('boot', {
10988
+ kind: 'system',
10989
+ text: t('onboard.agentPlaneAdded', {
10990
+ path: rosterPatchPath(resolveDshHome(), profile),
10991
+ profile,
10992
+ }),
10993
+ }));
10994
+ this.refreshRosterHealth();
10995
+ this.markDirty();
10996
+ }
10997
+ }
10998
+ catch (error) {
10999
+ // A broken or unwritable patch must not be swallowed: the workspace can
11000
+ // still boot, but the missing tools stay visible in the footer and
11001
+ // `/doctor` explains how to repair the file.
11002
+ this.pushRow(represent('boot', {
11003
+ kind: 'error',
11004
+ text: t('onboard.agentPlaneFailed', {
11005
+ error: errorChain(error),
11006
+ path: rosterPatchPath(resolveDshHome(), profile),
11007
+ }),
11008
+ }));
11009
+ this.markDirty();
11010
+ }
11011
+ }
11012
+ /**
11013
+ * Add only the profile rows whose tools are missing from this composition.
11014
+ *
11015
+ * A row that is already present in the patch is never written again, even if
11016
+ * its service failed to activate: the planner sees overrides and disables by
11017
+ * id or module name, and `ensureRosterRows` creates a backup first. This is
11018
+ * the first-run/upgrade repair, not an install-hook claim: it changes what
11019
+ * the next boot composes, not what this process has already composed.
11020
+ */
11021
+ async ensureProfileAgentPlane(profile = profileFromArgv()) {
11022
+ const generation = this.settingsGeneration;
11023
+ const tools = this.agentPlaneTools();
11024
+ const missing = generation === 'forms'
11025
+ ? rosterRows(generation).filter(row => row.id === 'tool-ask-user' ? !tools.askUser : !tools.present)
11026
+ : rosterRows(generation).filter(row => row.id === 'agent-presets'
11027
+ ? this.ctx.get('agentPresets') === undefined
11028
+ : row.id === 'code-runtime'
11029
+ ? !this.hasHostService('codeRuntime')
11030
+ : true);
11031
+ if (missing.length === 0)
11032
+ return 'present';
11033
+ return ensureRosterRows(resolveDshHome(), profile, missing, generation);
11034
+ }
10931
11035
  /** Re-read the roster fact this process was composed with (cheap, no IO). */
10932
11036
  refreshRosterHealth() {
10933
11037
  // 0.1.7 has no roster to be missing: what a terminal profile owns there is
10934
- // the agent-plane rows, so the chip reports those instead.
11038
+ // the agent-plane tools, so the chip follows the tools the composition
11039
+ // actually registered. Reading the patch alone would call a broken load
11040
+ // healthy as soon as the row text was written.
10935
11041
  this.rosterMissing = this.settingsGeneration === 'forms'
10936
- ? !formsRowsDeclared(resolveDshHome(), profileFromArgv())
11042
+ ? !this.agentPlaneReady()
10937
11043
  : this.ctx.get('agentPresets') === undefined;
10938
11044
  }
10939
11045
  /** Which settings protocol this host speaks; decides what "missing" means. */
@@ -10954,6 +11060,14 @@ export class SshTui {
10954
11060
  ? [first, t('mode.formsHost'), t('mode.formsHint', { profile, patch })].join('\n')
10955
11061
  : [first, t('mode.missingServiceHint', { profile, patch })].join('\n');
10956
11062
  }
11063
+ /**
11064
+ * `/mode fix`: write the missing profile rows into the user patch layer.
11065
+ *
11066
+ * The write only takes effect on the next launch — the loader composes the
11067
+ * patch tree once at boot — so the report says so instead of pretending the
11068
+ * running session gained a roster. The helper consults the actual tools, so
11069
+ * a row a bundle already supplies is not mounted a second time.
11070
+ */
10957
11071
  async repairRoster() {
10958
11072
  const profile = profileFromArgv();
10959
11073
  const patch = rosterPatchPath(resolveDshHome(), profile);
@@ -10962,8 +11076,7 @@ export class SshTui {
10962
11076
  // process-wide and owns the agent-plane rows instead of the roster. Both
10963
11077
  // the list and the header come from the same generation `/doctor` uses,
10964
11078
  // or this writes rows the running host cannot resolve.
10965
- const generation = this.settingsGeneration;
10966
- const result = await ensureRosterRows(resolveDshHome(), profile, rosterRows(generation), generation);
11079
+ const result = await this.ensureProfileAgentPlane(profile);
10967
11080
  this.refreshRosterHealth();
10968
11081
  this.pushRow(represent('repair-result', {
10969
11082
  kind: 'system',
@@ -13032,7 +13145,7 @@ export class SshTui {
13032
13145
  this.setupMessage(state, 'system', t('onboard.baseSaved', { path: displayDshPath('settings.yaml') }));
13033
13146
  }
13034
13147
  if (saved) {
13035
- this.onboardDone(t('onboard.officialDone', { model }));
13148
+ await this.onboardDone(t('onboard.officialDone', { model }));
13036
13149
  }
13037
13150
  }
13038
13151
  else {
@@ -13205,7 +13318,7 @@ export class SshTui {
13205
13318
  this.onSelectionChanged?.(selection);
13206
13319
  await this.rememberRoute(selection);
13207
13320
  await this.syncSubagentToProvider(ownerId, state.models, previousParentProvider !== ownerId);
13208
- this.onboardDone(t('onboard.customDone', { id: ownerId, model }));
13321
+ await this.onboardDone(t('onboard.customDone', { id: ownerId, model }));
13209
13322
  }
13210
13323
  }
13211
13324
  }
@@ -13249,8 +13362,40 @@ export class SshTui {
13249
13362
  * answer: `command-status` routes to the transcript, exactly like the boot line
13250
13363
  * that asked for the setup in the first place.
13251
13364
  */
13252
- onboardDone(text) {
13365
+ async onboardDone(text) {
13366
+ const profile = profileFromArgv();
13367
+ let repairNote;
13368
+ try {
13369
+ // The wizard wrote settings and the model selection through the profile
13370
+ // patch. Only after those writes are complete does the agent-plane repair
13371
+ // run, so it cannot be undone by the wizard's own patch update.
13372
+ const repaired = await this.ensureProfileAgentPlane(profile);
13373
+ if (repaired === 'written') {
13374
+ repairNote = t('onboard.agentPlaneAdded', {
13375
+ path: rosterPatchPath(resolveDshHome(), profile),
13376
+ profile,
13377
+ });
13378
+ }
13379
+ else if (this.settingsGeneration === 'forms' && !this.agentPlaneReady()) {
13380
+ // The rows are there but this process was composed before them (or a
13381
+ // row failed to activate). Saying "saved" alone would hide that the
13382
+ // tools are still absent from this session.
13383
+ repairNote = t('onboard.agentPlaneRestart', {
13384
+ path: rosterPatchPath(resolveDshHome(), profile),
13385
+ profile,
13386
+ });
13387
+ }
13388
+ }
13389
+ catch (error) {
13390
+ repairNote = t('onboard.agentPlaneFailed', {
13391
+ error: errorChain(error),
13392
+ path: rosterPatchPath(resolveDshHome(), profile),
13393
+ });
13394
+ }
13253
13395
  this.pushRow(represent('command-status', { kind: 'system', text }));
13396
+ if (repairNote !== undefined) {
13397
+ this.pushRow(represent('runtime-warning', { kind: 'system', text: repairNote }));
13398
+ }
13254
13399
  this.markDirty();
13255
13400
  }
13256
13401
  /** Store one credential, falling back to a launch-environment override on shadow/absence. */