reslop 0.3.0 → 0.3.2

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 (74) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/README.md +21 -3
  3. package/lib/agents/acp.js +309 -0
  4. package/lib/agents/agents.js +166 -370
  5. package/lib/agents/allow.js +141 -52
  6. package/lib/agents/allow.json +16 -13
  7. package/lib/agents/models.js +250 -376
  8. package/lib/agents/registry.js +437 -0
  9. package/lib/agents/run.js +234 -0
  10. package/lib/agents/transcript.js +143 -0
  11. package/lib/cli.js +2 -1
  12. package/lib/common/process.js +26 -1
  13. package/lib/dashboard/git.js +11 -1
  14. package/lib/dashboard/reports.js +80 -0
  15. package/lib/dashboard/watch.js +43 -8
  16. package/lib/dashboard/workspace.js +19 -20
  17. package/lib/dashboard/worktrees.js +145 -0
  18. package/lib/git/deps.js +29 -0
  19. package/lib/git/dir.js +50 -0
  20. package/lib/git/git.js +1 -1
  21. package/lib/git/linked.js +152 -0
  22. package/lib/git/local.js +179 -0
  23. package/lib/git/status.js +126 -0
  24. package/lib/git/worktree.js +137 -31
  25. package/lib/input/actions.js +83 -2
  26. package/lib/input/editor.js +9 -0
  27. package/lib/input/keys.js +39 -3
  28. package/lib/input/line-edit.js +8 -3
  29. package/lib/render/agents.js +64 -36
  30. package/lib/render/branches.js +55 -27
  31. package/lib/render/field.js +19 -5
  32. package/lib/render/footer.js +14 -4
  33. package/lib/render/frame.js +16 -2
  34. package/lib/render/log.js +5 -4
  35. package/lib/render/menu.js +103 -6
  36. package/lib/render/name-edit.js +31 -0
  37. package/lib/render/repos.js +52 -8
  38. package/lib/render/status.js +22 -4
  39. package/lib/report/render.js +4 -7
  40. package/lib/report/run.js +16 -23
  41. package/lib/review/agent-instructions.md +13 -8
  42. package/lib/review/format.js +0 -6
  43. package/lib/runs/process.js +3 -3
  44. package/lib/runs/runs.js +27 -4
  45. package/lib/session/agents/agents.js +227 -194
  46. package/lib/session/agents/away.js +48 -0
  47. package/lib/session/agents/job.js +23 -86
  48. package/lib/session/agents/jobs.js +14 -27
  49. package/lib/session/agents/options.js +45 -101
  50. package/lib/session/agents/plans.js +53 -0
  51. package/lib/session/agents/view.js +34 -0
  52. package/lib/session/branches.js +18 -2
  53. package/lib/session/compose.js +20 -6
  54. package/lib/session/dispatch.js +43 -3
  55. package/lib/session/editor-pointer.js +98 -0
  56. package/lib/session/files-pane.js +3 -0
  57. package/lib/session/import.js +12 -1
  58. package/lib/session/lifecycle.js +70 -10
  59. package/lib/session/navigation.js +5 -0
  60. package/lib/session/npm/history.js +3 -1
  61. package/lib/session/npm/npm.js +111 -13
  62. package/lib/session/ops.js +3 -0
  63. package/lib/session/pointer.js +304 -46
  64. package/lib/session/session.js +17 -2
  65. package/lib/session/tasks.js +1 -1
  66. package/lib/session/terminal.js +2 -2
  67. package/lib/session/text-menu.js +50 -0
  68. package/lib/session/view.js +19 -2
  69. package/lib/session/workspace.js +179 -38
  70. package/lib/session/worktrees.js +280 -0
  71. package/lib/term/select.js +2 -1
  72. package/lib/term/themes/dark.json +4 -0
  73. package/lib/term/themes/light.json +4 -0
  74. package/package.json +2 -2
@@ -4,19 +4,8 @@ const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const { clamp } = require('../../common/utilities.js');
6
6
  const agents = require('../../agents/agents.js');
7
- const {
8
- detectAgents,
9
- canLogin,
10
- listedOf,
11
- listModels,
12
- startAgent,
13
- buildLaunch,
14
- buildLogin,
15
- needsAuth,
16
- probeAuth,
17
- emptyChoice,
18
- mergeModels,
19
- } = agents;
7
+ const { detectAgents, listModels, startAgent, buildLaunch } = agents;
8
+ const { emptyChoice, catalogOf, buildInstall } = agents;
20
9
  const review = require('../../review/review.js');
21
10
  const { latestReviewName, listReviewNames, planFileNames } = review;
22
11
  const { REVIEW_DIR } = require('../../common/files.js');
@@ -24,16 +13,18 @@ const render = require('../../render/log.js');
24
13
  const { logRowCount } = render;
25
14
  const { RunPanel } = require('../run-panel.js');
26
15
  const options = require('./options.js');
27
- const { AgentOptions, concrete, restoreChoice, storedChoice } = options;
16
+ const { AgentOptions, restoreChoice, storedChoice } = options;
28
17
  const jobs = require('./jobs.js');
18
+ const { AwayRuns } = require('./away.js');
29
19
  const { readPrefs, writePrefs } = require('../prefs.js');
30
20
  const { elapsedLabel, launchJob, assignLog, stemOf } = jobs;
31
21
  const { restoreJobs, readRunList, saveRunFiles } = jobs;
32
22
  const { panelSessions, orderedSessions, runBlock } = jobs;
33
23
  const { runningJobs, withLiveRuns } = jobs;
34
- const { replayLaunch } = jobs;
35
24
  const plans = require('./plans.js');
36
25
  const { planKey, planProgress, planPart, planLabel, alignPlanLabels } = plans;
26
+ const { planCaption } = plans;
27
+ const { NO_PICK, NO_LIST, NO_JOB } = require('./view.js');
37
28
  const { MenuPick, menuRows, pickDelta } = require('../../input/menu-pick.js');
38
29
  const { lineKey } = require('../../input/line-edit.js');
39
30
 
@@ -41,7 +32,9 @@ const optionsOf = (row) => new AgentOptions(row);
41
32
 
42
33
  const APPLY_FIELD = {
43
34
  model: (choices, picked, value) => choices.pickModel(picked, value),
44
- context: (choices, picked, value) => choices.pickContext(picked, value),
35
+ context: (choices, picked, value) => {
36
+ picked.context = value;
37
+ },
45
38
  effort: (choices, picked, value) => {
46
39
  picked.effort = value;
47
40
  },
@@ -61,9 +54,9 @@ const continuedId = (session) => {
61
54
 
62
55
  const cacheKey = (row) => `${row.id}:${row.bin}`;
63
56
 
64
- const listQuietly = async (list, row, env) => {
57
+ const listQuietly = async (list, row, options) => {
65
58
  try {
66
- return await list(row.bin, row.spec, { env });
59
+ return await list(row, options);
67
60
  } catch {
68
61
  return null;
69
62
  }
@@ -75,41 +68,12 @@ const firstEnabled = (items, at, isOff) => {
75
68
  return free < 0 ? at : free;
76
69
  };
77
70
 
78
- const NO_PICK = {
79
- agentPick: '',
80
- agentMenu: null,
81
- agentQuery: '',
82
- agentEditor: null,
83
- agentMenuOff: [],
84
- agentPickCursor: 0,
85
- agentMenuScroll: 0,
86
- };
87
-
88
- const NO_LIST = {
89
- agents: null,
90
- agentRuns: [],
91
- agentRunCursor: 0,
92
- agentRunScroll: 0,
93
- agentFocus: 'cli',
94
- agentPlanCount: 0,
95
- agentView: false,
96
- };
97
-
98
- const NO_JOB = {
99
- agentOutput: '',
100
- agentCommand: '',
101
- agentElapsed: '',
102
- agentProgress: '',
103
- agentRunning: false,
104
- agentStatus: '',
105
- };
106
-
107
71
  class AgentsController extends RunPanel {
108
72
  constructor(ui) {
109
73
  super(ui);
110
74
  this.spawn = startAgent;
111
75
  this.listModels = listModels;
112
- this.authProbe = probeAuth;
76
+ this.probes = null;
113
77
  this.reset();
114
78
  }
115
79
 
@@ -122,8 +86,12 @@ class AgentsController extends RunPanel {
122
86
  this.list = [];
123
87
  this.choices = new Map();
124
88
  this.loaded = new Map();
89
+ this.asking = new Set();
125
90
  this.modelsEpoch = 0;
126
91
  this.jobs = [];
92
+ this.away = new AwayRuns();
93
+ this.installs = new Map();
94
+ this.installTarget = '';
127
95
  this.nextId = 1;
128
96
  this.viewing = false;
129
97
  this.viewId = 0;
@@ -139,31 +107,34 @@ class AgentsController extends RunPanel {
139
107
  this.viewSession = '';
140
108
  this.historyText = '';
141
109
  this.historyAt = 0;
142
- this.pendingStart = null;
143
- this.authYes = new Set();
144
- this.authWait = '';
145
110
  this.env = process.env;
146
111
  this.refresh();
147
112
  this.loadRuns();
148
113
  }
149
114
 
150
115
  killAll() {
151
- for (const job of this.jobs ?? []) {
116
+ if (this.probes) this.probes.abort();
117
+ this.probes = null;
118
+ for (const job of this.everyJob()) {
152
119
  if (job.child) job.child.kill();
153
120
  }
154
121
  }
155
122
 
123
+ // The install of an adapter is watched like a run but is no session
124
+ everyJob() {
125
+ const installs = this.installs ? this.installs.values() : [];
126
+ const runs = this.jobs ?? [];
127
+ const away = this.away ? this.away.jobs() : [];
128
+ return [...runs, ...away, ...installs];
129
+ }
130
+
156
131
  reviewStore() {
157
132
  return this.ui.review ? this.ui.review.store : null;
158
133
  }
159
134
 
160
135
  applyCached(row) {
161
136
  const cached = this.loaded.get(cacheKey(row));
162
- if (!cached) return;
163
- row.models = cached.models;
164
- row.wide = cached.wide;
165
- row.listedEfforts = cached.efforts;
166
- row.modelEfforts = cached.modelEfforts;
137
+ if (cached) row.catalog = cached;
167
138
  }
168
139
 
169
140
  refresh(env = this.env) {
@@ -173,7 +144,7 @@ class AgentsController extends RunPanel {
173
144
  this.choices = new Map();
174
145
  for (const row of this.list) {
175
146
  this.applyCached(row);
176
- this.choices.set(row.id, prev.get(row.id) ?? emptyChoice(row));
147
+ this.choices.set(row.id, prev.get(row.id) ?? emptyChoice());
177
148
  }
178
149
  this.loadChoices();
179
150
  this.clampCursor();
@@ -184,7 +155,7 @@ class AgentsController extends RunPanel {
184
155
  const saved = readPrefs(this.root).agents;
185
156
  for (const row of this.list) {
186
157
  const picked = this.choice(row.id);
187
- restoreChoice(picked, saved[row.id], row.spec);
158
+ restoreChoice(picked, saved[row.id]);
188
159
  optionsOf(row).preferEffort(picked, 'medium');
189
160
  }
190
161
  }
@@ -195,7 +166,7 @@ class AgentsController extends RunPanel {
195
166
  if (!this.root || !picked || !row) return;
196
167
  const data = readPrefs(this.root);
197
168
  const saved = { ...data.agents };
198
- const stored = storedChoice(picked, row.spec);
169
+ const stored = storedChoice(picked);
199
170
  if (stored) saved[id] = stored;
200
171
  else delete saved[id];
201
172
  writePrefs(this.root, { ...data, agents: saved });
@@ -210,22 +181,36 @@ class AgentsController extends RunPanel {
210
181
  }
211
182
 
212
183
  // The repository can change after construction: its runs and its saved
213
- // sessions must come from the same directory
184
+ // sessions must come from the same directory. A run that goes on in the
185
+ // previous one stays with it
214
186
  syncRuns() {
215
187
  if (!this.root || this.root === this.runsRoot) return;
216
- const live = this.jobs.filter((job) => job.status === 'running');
217
- this.loadRuns();
218
- for (const job of live) {
219
- job.id = this.nextId;
220
- this.nextId += 1;
221
- this.jobs.push(job);
222
- }
188
+ this.away.park(this.runsRoot, this.jobs);
189
+ const parked = this.away.take(this.root);
190
+ if (parked) this.jobs = parked;
191
+ else this.loadRuns();
192
+ this.runsRoot = this.root;
193
+ const last = this.jobs.reduce((max, job) => Math.max(max, job.id), 0);
194
+ this.nextId = Math.max(this.nextId, last + 1);
223
195
  this.viewId = 0;
224
196
  }
225
197
 
198
+ retarget() {
199
+ this.viewing = false;
200
+ this.viewId = 0;
201
+ this.pick = null;
202
+ this.viewSession = '';
203
+ }
204
+
205
+ // The runs in work by directory, for the screen of worktrees
206
+ liveByRoot() {
207
+ return this.away.counts(this.runsRoot, this.jobs);
208
+ }
209
+
226
210
  saveRuns() {
227
211
  if (!this.root) return;
228
- saveRunFiles(this.root, this.jobs);
212
+ saveRunFiles(this.runsRoot || this.root, this.jobs);
213
+ this.away.save();
229
214
  }
230
215
 
231
216
  clampCursor() {
@@ -243,8 +228,8 @@ class AgentsController extends RunPanel {
243
228
 
244
229
  choice(id) {
245
230
  const row = this.cliById(id);
246
- if (!row) return emptyChoice(null);
247
- if (!this.choices.has(id)) this.choices.set(id, emptyChoice(row));
231
+ if (!row) return emptyChoice();
232
+ if (!this.choices.has(id)) this.choices.set(id, emptyChoice());
248
233
  return this.choices.get(id);
249
234
  }
250
235
 
@@ -255,13 +240,14 @@ class AgentsController extends RunPanel {
255
240
  const picked = this.choice(row.id);
256
241
  const choices = optionsOf(row);
257
242
  const model = choices.normalize(picked);
243
+ const { effort, fast, context } = choices.settings(picked);
258
244
  return {
259
245
  ...row,
260
246
  model,
261
- effort: concrete(picked.effort),
262
- efforts: choices.efforts(picked.model),
263
- fast: picked.fast ? 'on' : 'off',
264
- context: concrete(picked.context),
247
+ effort,
248
+ efforts: choices.efforts(model),
249
+ fast,
250
+ context,
265
251
  contexts: choices.contexts(model),
266
252
  plan: row.bin ? plan : '',
267
253
  planBusy: Boolean(row.bin && planBusy),
@@ -316,25 +302,21 @@ class AgentsController extends RunPanel {
316
302
  }
317
303
 
318
304
  jobById(id) {
319
- return this.jobs.find((job) => job.id === id) ?? null;
305
+ return this.everyJob().find((job) => job.id === id) ?? null;
320
306
  }
321
307
 
322
308
  viewedJob() {
323
309
  return this.jobById(this.viewId);
324
310
  }
325
311
 
326
- runningJob(id, action = '') {
327
- const live = (job) => {
328
- const sameCli = job.cliId === id;
329
- const running = job.status === 'running';
330
- const sameAction = !action || job.action === action;
331
- return sameCli && running && sameAction;
332
- };
312
+ runningJob(id) {
313
+ const live = (job) => job.cliId === id && job.status === 'running';
333
314
  return this.jobs.findLast(live) ?? null;
334
315
  }
335
316
 
336
317
  runningCount() {
337
- return this.jobs.filter((job) => job.status === 'running').length;
318
+ const live = (job) => job.status === 'running';
319
+ return this.everyJob().filter(live).length;
338
320
  }
339
321
 
340
322
  summary() {
@@ -378,12 +360,20 @@ class AgentsController extends RunPanel {
378
360
 
379
361
  listView(onAgents) {
380
362
  if (!onAgents) return NO_LIST;
363
+ const agentRuns = this.sessionRows();
364
+ const selected = agentRuns[this.runCursor];
365
+ const running = Boolean(selected && selected.status === 'running');
366
+ const fresh = Boolean(selected && selected.fresh);
367
+ const resume = Boolean(continuedId(selected));
368
+ const agentCanStart = !running && (fresh || resume);
381
369
  return {
382
370
  agents: this.rows(),
383
- agentRuns: this.sessionRows(),
371
+ agentInstallPackage: this.installPackage(),
372
+ agentRuns,
384
373
  agentRunCursor: this.runCursor,
385
374
  agentRunScroll: this.runScroll,
386
375
  agentFocus: this.focus,
376
+ agentCanStart,
387
377
  agentPlanCount: this.reviewChoices().length,
388
378
  agentView: this.viewing,
389
379
  };
@@ -401,7 +391,7 @@ class AgentsController extends RunPanel {
401
391
  if (!job) return NO_JOB;
402
392
  const running = job.status === 'running';
403
393
  const name = stemOf(job.logName);
404
- const agentCommand = name || job.command;
394
+ const agentCommand = job.title || name || job.command;
405
395
  return {
406
396
  agentOutput: job.output,
407
397
  agentCommand,
@@ -457,25 +447,52 @@ class AgentsController extends RunPanel {
457
447
  async loadModels() {
458
448
  this.modelsEpoch += 1;
459
449
  const epoch = this.modelsEpoch;
460
- const pending = this.list
461
- .filter((row) => row.bin && !this.loaded.has(cacheKey(row)))
462
- .map((row) => this.fetchModels(row, epoch));
463
- if (!pending.length) return;
464
- await Promise.all(pending);
450
+ const waiting = this.list.filter(
451
+ (row) => row.bin && !this.loaded.has(cacheKey(row)),
452
+ );
453
+ if (!waiting.length) return;
454
+ await Promise.all(waiting.map((row) => this.fetchModels(row, epoch)));
465
455
  if (epoch === this.modelsEpoch) this.ui.paint();
466
456
  }
467
457
 
458
+ probeOptions(model) {
459
+ this.probes ??= new AbortController();
460
+ const { signal } = this.probes;
461
+ const models = model ? [model] : [];
462
+ return { env: this.env, cwd: this.root, signal, models };
463
+ }
464
+
465
+ // The agent lists its models only to a session, so one is opened to ask
468
466
  async fetchModels(row, epoch) {
469
- const listed = listedOf(await listQuietly(this.listModels, row, this.env));
470
- if (epoch !== this.modelsEpoch) return;
471
- const { spec } = row;
472
- const models = mergeModels(spec.models, listed.names, spec.partialList);
473
- const wide = listed.wide ? new Set(listed.wide) : null;
474
- const { efforts, modelEfforts } = listed;
475
- const cached = { models, wide, efforts, modelEfforts };
476
- this.loaded.set(cacheKey(row), cached);
467
+ const options = this.probeOptions(this.choice(row.id).model);
468
+ const catalog = await listQuietly(this.listModels, row, options);
469
+ if (epoch !== this.modelsEpoch || !catalog) return;
470
+ this.loaded.set(cacheKey(row), catalog);
471
+ const current = this.cliById(row.id);
472
+ if (!current || current.bin !== row.bin) return;
473
+ this.applyCached(current);
474
+ optionsOf(current).preferEffort(this.choice(current.id), 'medium');
475
+ }
476
+
477
+ // The settings of a model come from asking the agent about that model
478
+ async describeModel(row, model) {
479
+ const key = cacheKey(row);
480
+ const mark = `${key}:${model}`;
481
+ const known = this.loaded.get(key);
482
+ if (!known || !catalogOf(known).unknown(model)) return;
483
+ if (this.asking.has(mark)) return;
484
+ this.asking.add(mark);
485
+ const options = this.probeOptions(model);
486
+ const found = await listQuietly(this.listModels, row, options);
487
+ this.asking.delete(mark);
488
+ const cached = this.loaded.get(key);
489
+ if (!cached || !found || !(found.byModel instanceof Map)) return;
490
+ const prior = cached.byModel ?? [];
491
+ const byModel = new Map([...prior, ...found.byModel]);
492
+ this.loaded.set(key, { ...cached, byModel });
477
493
  const current = this.cliById(row.id);
478
494
  if (current && current.bin === row.bin) this.applyCached(current);
495
+ this.ui.paint();
479
496
  }
480
497
 
481
498
  logWidth() {
@@ -552,9 +569,7 @@ class AgentsController extends RunPanel {
552
569
  const choices = optionsOf(row);
553
570
  const picked = this.choice(row.id);
554
571
  if (field === 'model') return choices.names;
555
- if (field === 'context') {
556
- return choices.contexts(choices.shown(picked.model));
557
- }
572
+ if (field === 'context') return choices.contexts(picked.model);
558
573
  return choices.efforts(picked.model);
559
574
  }
560
575
 
@@ -565,9 +580,10 @@ class AgentsController extends RunPanel {
565
580
  return firstEnabled(items, Math.max(0, at), this.busyCheck());
566
581
  }
567
582
  const picked = this.choice(row.id);
568
- const model = field === 'model';
569
- const current = model ? optionsOf(row).shown(picked.model) : picked[field];
570
- return Math.max(0, items.indexOf(current));
583
+ const choices = optionsOf(row);
584
+ const shown = { ...choices.settings(picked) };
585
+ shown.model = choices.shown(picked.model);
586
+ return Math.max(0, items.indexOf(shown[field]));
571
587
  }
572
588
 
573
589
  menuItems() {
@@ -613,7 +629,7 @@ class AgentsController extends RunPanel {
613
629
  if (!row || !row.bin) return;
614
630
  optionsOf(row).normalize(this.choice(row.id));
615
631
  const items = this.fieldItems(field, row);
616
- if (items.length < (field === 'context' ? 1 : 2)) return;
632
+ if (items.length < 2) return;
617
633
  this.pick = new MenuPick(field, this.fieldStart(field, items, row));
618
634
  this.ui.status = '';
619
635
  this.ui.paint();
@@ -646,6 +662,7 @@ class AgentsController extends RunPanel {
646
662
  const apply = APPLY_FIELD[field] ?? APPLY_FIELD.effort;
647
663
  apply(optionsOf(row), picked, value);
648
664
  this.saveChoice(row.id);
665
+ if (field === 'model') void this.describeModel(row, picked.model);
649
666
  return `${field} ${picked[field]}`;
650
667
  }
651
668
 
@@ -672,9 +689,9 @@ class AgentsController extends RunPanel {
672
689
  return;
673
690
  }
674
691
  const picked = this.choice(row.id);
675
- optionsOf(row).toggleFast(picked);
676
- this.saveChoice(row.id);
677
- this.ui.status = picked.fast ? 'fast on' : 'fast off';
692
+ const choices = optionsOf(row);
693
+ if (choices.toggleFast(picked)) this.saveChoice(row.id);
694
+ this.ui.status = `fast ${choices.settings(picked).fast}`;
678
695
  this.ui.paint();
679
696
  }
680
697
 
@@ -710,7 +727,7 @@ class AgentsController extends RunPanel {
710
727
  busyPlans() {
711
728
  const busy = new Set();
712
729
  for (const job of this.jobs) {
713
- if (job.status !== 'running' || job.action === 'login') continue;
730
+ if (job.status !== 'running') continue;
714
731
  const name = path.basename(`${job.plan ?? ''}`);
715
732
  if (name) busy.add(name);
716
733
  }
@@ -758,12 +775,10 @@ class AgentsController extends RunPanel {
758
775
  }
759
776
 
760
777
  closeView() {
761
- const job = this.viewedJob();
762
778
  this.viewing = false;
763
779
  this.viewSession = '';
764
780
  this.historyText = '';
765
- const waiting = job && job.action === 'login' && job.status === 'running';
766
- this.ui.status = waiting ? 'logging in' : '';
781
+ this.ui.status = '';
767
782
  this.ui.paint();
768
783
  }
769
784
 
@@ -782,6 +797,7 @@ class AgentsController extends RunPanel {
782
797
  showOutput(job, text) {
783
798
  job.output = text;
784
799
  job.dirty = true;
800
+ job.captureStats();
785
801
  this.keepLog(job);
786
802
  const watched = this.viewing && this.viewId === job.id;
787
803
  if (watched || this.ui.nav.pane === 'agents') this.ui.paint();
@@ -800,77 +816,13 @@ class AgentsController extends RunPanel {
800
816
  if (job.status !== 'running') return;
801
817
  const stopped = job.stopping;
802
818
  this.settleJob(job, stopped ? null : result);
803
- const isLogin = job.action === 'login';
804
- const watched = this.viewing && this.viewId === job.id;
805
- const signedIn = !stopped && isLogin && result.status === 0;
806
- if (stopped && watched) this.ui.status = 'stopped';
807
- else if (signedIn) this.ui.status = 'logged in';
808
- this.ui.paint();
809
- if (isLogin) return void this.finishLogin(job, result, stopped, signedIn);
810
- if (stopped || !needsAuth(job.output)) return;
811
- const cli = this.cliById(job.cliId);
812
- if (canLogin(cli)) this.startLogin(cli);
813
- }
814
-
815
- finishLogin(job, result, stopped, signedIn) {
816
- if (!signedIn) {
817
- this.pendingStart = null;
818
- if (!stopped && result) this.ui.status = 'not logged in';
819
- this.ui.paint();
820
- return;
821
- }
822
- this.authYes.add(job.cliId);
823
- const pending = this.pendingStart;
824
- this.pendingStart = null;
825
- if (!pending || pending.id !== job.cliId) return;
826
- const cli = this.cliById(pending.id);
827
- if (cli) this.launchReady(cli, pending.session || '');
828
- }
829
-
830
- authAnswer(row) {
831
- if (!canLogin(row)) return true;
832
- const args = row.spec && row.spec.status;
833
- if (!args || !args.length) return true;
834
- if (this.authYes.has(row.id)) return true;
835
- return this.authProbe(row);
836
- }
837
-
838
- beginStart(row, sessionId = '') {
839
- const id = row.id;
840
- if (this.authWait === id) return;
841
- const answer = this.authAnswer(row);
842
- const go = (ok) => {
843
- if (this.authWait === id) this.authWait = '';
844
- const current = this.cliById(id);
845
- if (!current) return;
846
- this.afterAuth(current, sessionId, ok);
847
- };
848
- if (answer && typeof answer.then === 'function') {
849
- this.authWait = id;
850
- answer.then(go, () => go(null));
851
- return;
819
+ if (stopped && this.viewing && this.viewId === job.id) {
820
+ this.ui.status = 'stopped';
852
821
  }
853
- this.afterAuth(row, sessionId, answer);
854
- }
855
-
856
- afterAuth(row, sessionId, ok) {
857
- if (ok === true) this.authYes.add(row.id);
858
- if (ok === false) return void this.holdForLogin(row, sessionId);
859
- this.launchReady(row, sessionId);
860
- }
861
-
862
- holdForLogin(row, sessionId) {
863
- this.pendingStart = { id: row.id, session: sessionId };
864
- this.startLogin(row);
865
- this.ui.status = 'not logged in';
866
822
  this.ui.paint();
867
823
  }
868
824
 
869
- launchReady(row, sessionId = '') {
870
- this.startJob(row, sessionId);
871
- }
872
-
873
- startLaunch(launch, model, showLog = true) {
825
+ startLaunch(launch, model) {
874
826
  if (!launch.ok) {
875
827
  this.ui.status = launch.error;
876
828
  return;
@@ -885,14 +837,15 @@ class AgentsController extends RunPanel {
885
837
  if (job.status === 'running') job.child = child;
886
838
  this.saveRuns();
887
839
  this.syncProgress();
888
- if (showLog && this.jobs.at(-1) === job) this.openView(job.id);
840
+ if (this.jobs.at(-1) === job) this.openView(job.id);
889
841
  }
890
842
 
891
843
  startJob(row, sessionId = '') {
892
844
  const picked = this.choice(row.id);
893
845
  const plan = this.ensurePlan();
894
846
  const launch = buildLaunch(row, picked, plan, this.root, sessionId);
895
- this.startLaunch(launch, picked.model);
847
+ const title = planCaption(this.root, launch.plan);
848
+ this.startLaunch({ ...launch, title }, picked.model);
896
849
  }
897
850
 
898
851
  prepareStart() {
@@ -914,19 +867,90 @@ class AgentsController extends RunPanel {
914
867
  return row;
915
868
  }
916
869
 
917
- startLogin(row) {
918
- const live = this.runningJob(row.id, 'login');
919
- if (!live) this.startLaunch(buildLogin(row), '', false);
920
- if (this.viewing) this.closeView();
921
- this.ui.status = 'logging in';
922
- this.ui.paint();
923
- }
924
-
925
870
  rerun() {
926
871
  if (!this.viewing) return;
927
872
  const job = this.viewedJob();
928
- if (!job || job.status === 'running' || !job.cmd) return;
929
- this.startLaunch(replayLaunch(job), job.model);
873
+ if (!job || job.status === 'running') return;
874
+ if (job.action === 'install') {
875
+ this.closeView();
876
+ return void this.install(job.cliId);
877
+ }
878
+ const row = this.cliById(job.cliId);
879
+ const plan = job.plan ? path.resolve(this.root, job.plan) : '';
880
+ const launch = buildLaunch(row, job.choice, plan, this.root, job.resume);
881
+ const title = planCaption(this.root, launch.plan) || job.title;
882
+ this.startLaunch({ ...launch, title }, job.model);
883
+ }
884
+
885
+ installPackage() {
886
+ const row = this.cliById(this.installTarget);
887
+ return row ? row.package || row.installLabel : '';
888
+ }
889
+
890
+ proposeInstall() {
891
+ if (this.viewing || this.pick) return;
892
+ const row = this.selected();
893
+ const live = row ? this.installs.get(row.id) : null;
894
+ if (live && live.status === 'running') return void this.openView(live.id);
895
+ const launch = buildInstall(row);
896
+ if (!launch.ok) {
897
+ this.ui.status = launch.error;
898
+ return void this.ui.paint();
899
+ }
900
+ this.installTarget = row.id;
901
+ this.ui.status = '';
902
+ this.ui.mode = 'confirmAgentInstall';
903
+ this.ui.paint();
904
+ }
905
+
906
+ cancelInstall() {
907
+ this.installTarget = '';
908
+ this.ui.mode = 'review';
909
+ this.ui.paint();
910
+ }
911
+
912
+ confirmInstall() {
913
+ const id = this.installTarget;
914
+ this.installTarget = '';
915
+ this.ui.mode = 'review';
916
+ this.install(id);
917
+ }
918
+
919
+ install(id = '') {
920
+ if (this.viewing || this.pick) return;
921
+ const row = id ? this.cliById(id) : this.selected();
922
+ const launch = buildInstall(row);
923
+ if (!launch.ok) {
924
+ this.ui.status = launch.error;
925
+ return void this.ui.paint();
926
+ }
927
+ const job = launchJob(this.nextId, launch, '');
928
+ this.nextId += 1;
929
+ this.installs.set(row.id, job);
930
+ const onData = (text) => this.showOutput(job, text);
931
+ const onClose = (result) => this.finishInstall(job, result);
932
+ const child = this.spawn(this.root, launch, onData, onClose);
933
+ if (job.status === 'running') job.child = child;
934
+ this.syncProgress();
935
+ this.openView(job.id);
936
+ }
937
+
938
+ // Refresh PATH and managed registry locations after the installer exits.
939
+ finishInstall(job, result) {
940
+ if (job.status !== 'running') return;
941
+ const stopped = job.stopping;
942
+ this.settleJob(job, stopped ? null : result);
943
+ this.ui.status = stopped ? 'stopped' : this.installOutcome(job, result);
944
+ this.ui.paint();
945
+ }
946
+
947
+ installOutcome(job, result) {
948
+ if (result.status !== 0) return 'install failed';
949
+ this.refresh();
950
+ const row = this.cliById(job.cliId);
951
+ if (!row || !row.bin) return 'installed, but not found';
952
+ void this.loadModels();
953
+ return `installed ${job.name}`;
930
954
  }
931
955
 
932
956
  start() {
@@ -934,8 +958,17 @@ class AgentsController extends RunPanel {
934
958
  const session = this.selectedSession();
935
959
  if (this.focus === 'runs' && this.openRunRow(session)) return;
936
960
  const row = this.prepareStart();
937
- if (!row) return;
938
- this.beginStart(row, continuedId(session));
961
+ if (row) this.startJob(row, continuedId(session));
962
+ }
963
+
964
+ startSelected() {
965
+ if (this.viewing || this.pick) return;
966
+ const session = this.selectedSession();
967
+ if (!session || session.status === 'running') return;
968
+ const resume = session.fresh ? '' : continuedId(session);
969
+ if (!session.fresh && !resume) return;
970
+ const row = this.prepareStart();
971
+ if (row) this.startJob(row, resume);
939
972
  }
940
973
 
941
974
  selectedJob() {