@superjackfruitlabs/salaam 0.0.4 → 0.0.5

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 (97) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/src/acp-desk.js +140 -0
  3. package/dist/src/acp-outcome.js +23 -19
  4. package/dist/src/acp.js +258 -11
  5. package/dist/src/approval-rules.js +287 -0
  6. package/dist/src/attach.js +3 -1
  7. package/dist/src/autonomy.js +80 -9
  8. package/dist/src/cli-help.js +34 -4
  9. package/dist/src/cli.js +55 -3
  10. package/dist/src/command-danger.js +797 -0
  11. package/dist/src/command-sandbox.js +116 -6
  12. package/dist/src/custom-commands.js +159 -0
  13. package/dist/src/danger-classifier.js +286 -0
  14. package/dist/src/git-helpers.js +160 -0
  15. package/dist/src/hooks.js +351 -0
  16. package/dist/src/host.js +96 -4
  17. package/dist/src/house-extension.js +133 -7
  18. package/dist/src/local-coding.js +553 -58
  19. package/dist/src/local-edits.js +168 -6
  20. package/dist/src/mentions.js +277 -0
  21. package/dist/src/pi-sessions.js +5 -1
  22. package/dist/src/project-instructions.js +195 -0
  23. package/dist/src/protocol.js +4 -1
  24. package/dist/src/service.js +342 -5
  25. package/dist/src/session-pick.js +77 -0
  26. package/dist/src/shell-passthrough.js +39 -0
  27. package/dist/src/store.js +51 -4
  28. package/dist/src/supervisor.js +233 -22
  29. package/dist/src/task-context.js +8 -2
  30. package/dist/src/terminal-status.js +88 -0
  31. package/dist/src/tui-activity.js +99 -0
  32. package/dist/src/tui-classifier.js +25 -0
  33. package/dist/src/tui-client.js +186 -5
  34. package/dist/src/tui-commands.js +23 -5
  35. package/dist/src/tui-composer.js +113 -0
  36. package/dist/src/tui-keymap.js +116 -0
  37. package/dist/src/tui-mentions.js +38 -0
  38. package/dist/src/tui-modals.js +157 -34
  39. package/dist/src/tui-notify.js +62 -0
  40. package/dist/src/tui-review.js +55 -0
  41. package/dist/src/tui-turn-keys.js +52 -0
  42. package/dist/src/tui-view.js +243 -69
  43. package/dist/src/tui.js +875 -118
  44. package/dist/src/turn-checkpoints.js +221 -0
  45. package/dist/src/turn-rewind.js +168 -0
  46. package/dist/src/web-fetch.js +114 -0
  47. package/dist/src/web-guard.js +163 -0
  48. package/dist/src/web-search.js +302 -0
  49. package/npm-shrinkwrap.json +2 -2
  50. package/package.json +1 -1
  51. package/src/acp-desk.ts +155 -0
  52. package/src/acp-outcome.ts +25 -21
  53. package/src/acp.ts +338 -28
  54. package/src/approval-rules.ts +360 -0
  55. package/src/attach.ts +4 -1
  56. package/src/autonomy.ts +111 -10
  57. package/src/cli-help.ts +33 -4
  58. package/src/cli.ts +66 -2
  59. package/src/command-danger.ts +886 -0
  60. package/src/command-sandbox.ts +128 -8
  61. package/src/custom-commands.ts +175 -0
  62. package/src/danger-classifier.ts +321 -0
  63. package/src/git-helpers.ts +177 -0
  64. package/src/hooks.ts +425 -0
  65. package/src/host.ts +122 -1
  66. package/src/house-extension.ts +142 -7
  67. package/src/local-coding.ts +674 -75
  68. package/src/local-edits.ts +191 -8
  69. package/src/mentions.ts +304 -0
  70. package/src/pi-sessions.ts +9 -1
  71. package/src/project-instructions.ts +229 -0
  72. package/src/protocol.ts +19 -2
  73. package/src/service.ts +488 -5
  74. package/src/session-pick.ts +102 -0
  75. package/src/shell-passthrough.ts +38 -0
  76. package/src/store.ts +52 -3
  77. package/src/supervisor.ts +262 -24
  78. package/src/task-context.ts +9 -2
  79. package/src/terminal-status.ts +103 -0
  80. package/src/tui-activity.ts +133 -0
  81. package/src/tui-classifier.ts +38 -0
  82. package/src/tui-client.ts +239 -8
  83. package/src/tui-commands.ts +49 -10
  84. package/src/tui-composer.ts +124 -0
  85. package/src/tui-keymap.ts +152 -0
  86. package/src/tui-mentions.ts +43 -0
  87. package/src/tui-modals.ts +173 -43
  88. package/src/tui-notify.ts +77 -0
  89. package/src/tui-review.ts +76 -0
  90. package/src/tui-turn-keys.ts +77 -0
  91. package/src/tui-view.ts +341 -88
  92. package/src/tui.ts +1047 -137
  93. package/src/turn-checkpoints.ts +253 -0
  94. package/src/turn-rewind.ts +258 -0
  95. package/src/web-fetch.ts +140 -0
  96. package/src/web-guard.ts +195 -0
  97. package/src/web-search.ts +402 -0
package/dist/src/tui.js CHANGED
@@ -1,21 +1,34 @@
1
- import { Editor, CombinedAutocompleteProvider, ProcessTerminal, TuiAltScreen, ScrollView, VStack, HStack, matchesKey, } from '@earendil-works/pi-tui';
1
+ import { Editor, ProcessTerminal, TuiAltScreen, ScrollView, VStack, HStack, matchesKey, } from '@earendil-works/pi-tui';
2
+ import { extractMentions, isSensitivePath } from './mentions.js';
3
+ import { MentionAutocompleteProvider } from './tui-mentions.js';
2
4
  import { execFile, spawn } from 'node:child_process';
3
5
  import { promisify } from 'node:util';
6
+ import { cancelChoices, interactiveCoding, queuedItems, runningAttempt, turnKey, turnKeyAction, } from './tui-turn-keys.js';
4
7
  import { randomUUID } from 'node:crypto';
5
8
  import { join, resolve, basename } from 'node:path';
6
9
  import { existsSync, mkdtempSync, writeFileSync, openSync, readSync, closeSync, fstatSync, constants, rmSync, } from 'node:fs';
7
10
  import { tmpdir } from 'node:os';
8
11
  import { ROOT } from './config.js';
9
12
  import { PI_PROVIDERS, REASONING_LEVELS, providerRoute, } from './provider-config.js';
10
- import { SLASH_COMMANDS, SLASH_COMPLETIONS, parseSlashCommand, slashHelp } from './tui-commands.js';
13
+ import { INIT_INSTRUCTION, SLASH_COMMANDS, SLASH_COMPLETIONS, parseSlashCommand, slashHelp, } from './tui-commands.js';
14
+ import { branchInstruction, commitInstruction, currentBranch, defaultBase, forgeTool, loadGitPrefs, prInstruction, validBranchName, } from './git-helpers.js';
11
15
  import { conversationExport, writeConversationExport } from './tui-export.js';
16
+ import { describeHooks } from './hooks.js';
17
+ import { expandCustomCommand, loadCustomCommands, matchCustomCommand, } from './custom-commands.js';
12
18
  import { credentialPath } from './credentials.js';
13
19
  import { call } from './transport.js';
20
+ import { notificationFor, notify, notifyMode, jobsText } from './tui-notify.js';
14
21
  import { OperatorClient } from './tui-client.js';
15
22
  import { SalaamTheme, safeText, shortId } from './tui-theme.js';
16
- import { Dynamic, DocumentView, header, tabs, status, sidebar, } from './tui-view.js';
17
- import { ChoiceModal, FormModal, ConfirmModal, ApprovalModal, TextModal } from './tui-modals.js';
23
+ import { Dynamic, DocumentView, header, modelLabel, tabs, status, sidebar, } from './tui-view.js';
24
+ import { nextMode } from './autonomy.js';
25
+ import { ChoiceModal, FormModal, ConfirmModal, ApprovalModal, approvalReason, onceOnlyReason, TextModal, } from './tui-modals.js';
18
26
  import { commandTerminal } from './coding.js';
27
+ import { rewindItems, rewindSummary } from './tui-review.js';
28
+ import { classifierItems, classifierNotice } from './tui-classifier.js';
29
+ import { GLOBAL_KEYS, REVIEW_KEYS, keySheet } from './tui-keymap.js';
30
+ import { HerdrReporter, statusEnabled, terminalState, titleFor } from './terminal-status.js';
31
+ import { ComposerHistory, ctrlCAction, historyPath, searchHistory, searchPrompt, shellPassthrough, } from './tui-composer.js';
19
32
  /** Frames are atomic. A slow terminal gets one fresh full redraw after drain. */
20
33
  export class SalaamTerminal extends ProcessTerminal {
21
34
  waiting = false;
@@ -115,6 +128,12 @@ export class SalaamApp {
115
128
  lastSession;
116
129
  lastRevision;
117
130
  historyKeys = new Set();
131
+ /** Messages this user sent, kept across sessions without secret-looking lines (Ctrl+R searches it). */
132
+ sent = new ComposerHistory(historyPath());
133
+ /** When Ctrl+C was last pressed on an empty composer; a second press within 2 s leaves. */
134
+ ctrlCArmed;
135
+ /** Reverse history search in progress: the query, the matching entry and the draft it replaces. */
136
+ search;
118
137
  removeInput;
119
138
  constructor(options) {
120
139
  this.options = options;
@@ -141,7 +160,9 @@ export class SalaamApp {
141
160
  }, { paddingX: 2 });
142
161
  // The app journals the exact expanded input before clearing it. The library's submit path trims and clears first.
143
162
  this.editor.disableSubmit = true;
144
- this.editor.setAutocompleteProvider(new CombinedAutocompleteProvider([...SLASH_COMPLETIONS], options.cwd ?? process.cwd(), null));
163
+ for (const entry of this.sent.entries)
164
+ this.editor.addToHistory(entry);
165
+ this.editor.setAutocompleteProvider(new MentionAutocompleteProvider([...SLASH_COMPLETIONS], options.cwd ?? process.cwd()));
145
166
  this.editor.onChange = () => {
146
167
  const text = this.editor.getExpandedText(), clean = safeText(text);
147
168
  if (clean !== text) {
@@ -150,6 +171,9 @@ export class SalaamApp {
150
171
  }
151
172
  if (this.client.snapshot)
152
173
  this.drafts.set(this.client.snapshot.id, clean);
174
+ // Starting a command reloads this workspace's custom commands into the completion list.
175
+ if (clean === '/')
176
+ this.refreshCommandCompletions();
153
177
  this.tui.requestRender();
154
178
  };
155
179
  this.document = new DocumentView(this.view);
@@ -199,12 +223,37 @@ export class SalaamApp {
199
223
  this.done = new Promise((resolve) => (this.finish = resolve));
200
224
  this.removeInput = this.tui.addInputListener((data) => this.input(data));
201
225
  this.client.on('change', () => this.changed());
226
+ this.client.on('durable', (event) => {
227
+ const message = notificationFor(event);
228
+ if (message)
229
+ notify(message, notifyMode());
230
+ });
202
231
  }
203
232
  contentWidth() {
204
233
  return Math.max(1, this.terminal.columns - (this.rail && this.terminal.columns >= 104 ? 31 : 0));
205
234
  }
235
+ // Trial polish C: the window title and Herdr say working / needs you / idle.
236
+ statusOn = statusEnabled();
237
+ herdr = new HerdrReporter();
238
+ lastTitle;
239
+ reportStatus() {
240
+ if (!this.statusOn)
241
+ return;
242
+ const snapshot = this.client.snapshot;
243
+ const state = terminalState(snapshot);
244
+ const name = snapshot
245
+ ? (this.client.names[snapshot.id] ?? (snapshot.cwd ? basename(snapshot.cwd) : undefined))
246
+ : undefined;
247
+ const title = titleFor(state, name);
248
+ if (title !== this.lastTitle) {
249
+ this.lastTitle = title;
250
+ this.terminal.setTitle(title);
251
+ }
252
+ this.herdr.report(state, snapshot?.id);
253
+ }
206
254
  changed() {
207
255
  const snapshot = this.client.snapshot;
256
+ this.reportStatus();
208
257
  if (snapshot) {
209
258
  if (snapshot.id !== this.lastSession) {
210
259
  if (this.lastSession)
@@ -224,6 +273,11 @@ export class SalaamApp {
224
273
  this.historyKeys.add(submission.id);
225
274
  this.editor.addToHistory(submission.text);
226
275
  }
276
+ // Phase 2b: the status line shows this session's mode; reload it when the session changes.
277
+ if (this.client.snapshot?.id !== this.modeSession) {
278
+ this.modeSession = this.client.snapshot?.id;
279
+ void this.loadMode().catch(() => { });
280
+ }
227
281
  if (this.client.workspace?.revision !== this.lastRevision) {
228
282
  this.lastRevision = this.client.workspace?.revision;
229
283
  this.view.diff = undefined;
@@ -236,16 +290,70 @@ export class SalaamApp {
236
290
  this.tui.requestRender();
237
291
  }
238
292
  input(data) {
239
- if (matchesKey(data, 'ctrl+c') ||
240
- (matchesKey(data, 'ctrl+d') && !this.editor.getExpandedText())) {
293
+ if (this.search)
294
+ return this.searchKey(data);
295
+ if (matchesKey(data, 'ctrl+c')) {
296
+ this.ctrlC();
297
+ return { consume: true };
298
+ }
299
+ this.ctrlCArmed = undefined;
300
+ if (matchesKey(data, 'ctrl+d') && !this.editor.getExpandedText()) {
241
301
  this.finish();
242
302
  return { consume: true };
243
303
  }
244
304
  if (this.modal)
245
305
  return;
306
+ if (matchesKey(data, 'ctrl+r')) {
307
+ this.startSearch();
308
+ return { consume: true };
309
+ }
246
310
  if (this.editor.isShowingAutocomplete() &&
247
311
  (matchesKey(data, 'enter') || matchesKey(data, 'tab') || matchesKey(data, 'escape')))
248
312
  return;
313
+ if (data === '?' && this.editor.getExpandedText() === '') {
314
+ this.help();
315
+ return { consume: true };
316
+ }
317
+ // Esc stops the running turn, Enter steers it, Tab queues behind it (interactive coding only).
318
+ const key = turnKey(data);
319
+ const action = key
320
+ ? turnKeyAction({
321
+ key,
322
+ text: this.editor.getExpandedText(),
323
+ snapshot: this.client.snapshot,
324
+ modal: false,
325
+ autocomplete: false,
326
+ })
327
+ : null;
328
+ if (action === 'interrupt') {
329
+ const attempt = runningAttempt(this.client.snapshot);
330
+ if (attempt)
331
+ void this.execute(async () => {
332
+ await this.client.cancel(attempt);
333
+ this.client.report('Stopped the turn. Edits already made stay; /undo reverts the last one. Your typed text is kept.');
334
+ });
335
+ return { consume: true };
336
+ }
337
+ if (action === 'steer' || action === 'queue') {
338
+ const text = this.editor.getExpandedText(), session = this.client.snapshot?.id;
339
+ void this.execute(async () => {
340
+ if (action === 'steer')
341
+ await this.client.steer(text);
342
+ else {
343
+ await this.client.submit(text);
344
+ const waiting = this.client.snapshot ? queuedItems(this.client.snapshot).length : 0;
345
+ this.client.report('Queued for after this turn (' + waiting + ' waiting). /queue edits or removes them.');
346
+ }
347
+ this.editor.addToHistory(text);
348
+ this.sent.add(text);
349
+ if (this.client.snapshot?.id === session && this.editor.getExpandedText() === text) {
350
+ this.editor.setText('');
351
+ if (session)
352
+ this.drafts.delete(session);
353
+ }
354
+ });
355
+ return { consume: true };
356
+ }
249
357
  if (matchesKey(data, 'enter')) {
250
358
  const text = this.editor.getExpandedText(), session = this.client.snapshot?.id;
251
359
  const submit = () => {
@@ -260,24 +368,12 @@ export class SalaamApp {
260
368
  submit();
261
369
  return { consume: true };
262
370
  }
263
- const keys = [
264
- ['ctrl+k', () => this.palette()],
265
- ['ctrl+p', () => void this.execute(() => this.sessions())],
266
- ['ctrl+n', () => void this.execute(() => this.newSession())],
267
- ['ctrl+o', () => this.approvals()],
268
- ['ctrl+g', () => this.setTab('changes')],
269
- ['ctrl+t', () => this.setTab('commands')],
270
- [
271
- 'ctrl+b',
272
- () => {
273
- this.rail = !this.rail;
274
- this.tui.requestRender(true);
275
- },
276
- ],
277
- ['ctrl+r', () => void this.execute(() => this.refresh())],
278
- ['ctrl+e', () => void this.execute(() => this.editPrompt())],
279
- ['f1', () => this.help()],
280
- ];
371
+ // Ctrl+K kills to the end of the line while the composer has text (readline); on an empty
372
+ // composer it opens the actions palette. F2 always opens it.
373
+ if (matchesKey(data, 'ctrl+k') && this.editor.getExpandedText() !== '')
374
+ return;
375
+ const actions = this.globalActions();
376
+ const keys = GLOBAL_KEYS.map((k) => [k.key, () => actions[k.action]()]);
281
377
  for (const [key, action] of keys)
282
378
  if (matchesKey(data, key)) {
283
379
  if (this.view.busy)
@@ -293,6 +389,11 @@ export class SalaamApp {
293
389
  this.setTab(names[(names.indexOf(this.view.tab) + 1) % names.length]);
294
390
  return { consume: true };
295
391
  }
392
+ if (this.view.tab === 'changes' && interactiveCoding(this.client.snapshot)) {
393
+ const handled = this.reviewKey(data);
394
+ if (handled)
395
+ return handled;
396
+ }
296
397
  if (this.view.tab === 'changes' &&
297
398
  (matchesKey(data, 'alt+right') || matchesKey(data, 'alt+left'))) {
298
399
  const offset = matchesKey(data, 'alt+left')
@@ -304,6 +405,30 @@ export class SalaamApp {
304
405
  }
305
406
  return;
306
407
  }
408
+ /** What each key in GLOBAL_KEYS does; the table owns the keys, this owns the actions. */
409
+ globalActions() {
410
+ return (this.actionTable ??= {
411
+ palette: () => this.palette(),
412
+ sessions: () => void this.execute(() => this.sessions()),
413
+ new: () => void this.execute(() => this.newSession()),
414
+ approvals: () => this.approvals(),
415
+ changes: () => this.setTab('changes'),
416
+ commands: () => this.setTab('commands'),
417
+ rail: () => {
418
+ this.rail = !this.rail;
419
+ this.tui.requestRender(true);
420
+ },
421
+ transcript: () => {
422
+ this.view.transcript = !this.view.transcript;
423
+ this.setTab('conversation');
424
+ },
425
+ refresh: () => void this.execute(() => this.refresh()),
426
+ editor: () => void this.execute(() => this.editPrompt()),
427
+ mode: () => void this.execute(() => this.cycleMode()),
428
+ help: () => this.help(),
429
+ });
430
+ }
431
+ actionTable;
307
432
  async execute(work) {
308
433
  if (this.view.busy) {
309
434
  const session = this.client.snapshot?.id ?? this.options.session;
@@ -351,7 +476,7 @@ export class SalaamApp {
351
476
  choose(item.value);
352
477
  }, () => this.terminal.rows));
353
478
  }
354
- confirm(title, description, label, confirm) {
479
+ confirm(title, description, label, confirm, confirmByDefault = false) {
355
480
  if (this.terminal.rows < 18) {
356
481
  this.client.report('Expand the terminal to at least 18 rows to review this action.');
357
482
  return;
@@ -359,7 +484,7 @@ export class SalaamApp {
359
484
  this.showModal(new ConfirmModal(title, description, label, this.view.theme, () => this.tui.requestRender(), () => this.closeModal(), () => {
360
485
  this.closeModal();
361
486
  void this.execute(confirm);
362
- }));
487
+ }, confirmByDefault));
363
488
  }
364
489
  async startService() {
365
490
  await this.client.list();
@@ -386,22 +511,50 @@ export class SalaamApp {
386
511
  if (this.client.connection === 'offline')
387
512
  throw new Error('Service did not become available. Inspect sal service status.');
388
513
  }
514
+ /** Interactive sessions switch in place between turns; otherwise the choice opens a new session. */
515
+ async applyRoute(route) {
516
+ if (this.client.snapshot?.provider_route && this.client.controlling) {
517
+ try {
518
+ await this.client.switchRoute(route);
519
+ this.client.notice = 'Now using ' + modelLabel(this.client) + ' in this session.';
520
+ return;
521
+ }
522
+ catch (error) {
523
+ // A route that limits output differently cannot join this session. Keep the choice and
524
+ // open the new session with it, rather than leaving the old model selected.
525
+ if (error.code !== 'provider_output_cap_changed')
526
+ throw error;
527
+ this.selectedRoute = route;
528
+ this.client.notice = `${route.model ?? route.provider} limits output differently from this session's model, so it starts in a new session. This session stays as it is.`;
529
+ await this.newSession();
530
+ return;
531
+ }
532
+ }
533
+ this.selectedRoute = route;
534
+ await this.newSession();
535
+ }
389
536
  modelPicker() {
390
- this.choice('Provider · applies to a new session', [
537
+ const configured = (this.client.snapshot?.provider_route ?? this.client.health?.provider_route)
538
+ ?.provider;
539
+ const live = PI_PROVIDERS.filter((p) => p.getModels().length);
540
+ this.choice(this.client.snapshot ? 'Provider · switches this session' : 'Provider', [
541
+ // The configured provider first; the scripted demo last, never the default.
542
+ ...[
543
+ ...live.filter((p) => p.id === configured),
544
+ ...live.filter((p) => p.id !== configured),
545
+ ].map((p) => ({
546
+ value: p.id,
547
+ label: p.name,
548
+ description: p.id + (p.id === configured ? ' · current' : ''),
549
+ })),
391
550
  {
392
551
  value: 'offline',
393
552
  label: 'Offline demo',
394
553
  description: 'Scripted fixture, not an AI model',
395
554
  },
396
- ...PI_PROVIDERS.filter((p) => p.getModels().length).map((p) => ({
397
- value: p.id,
398
- label: p.name,
399
- description: p.id,
400
- })),
401
555
  ], (provider) => {
402
556
  if (provider === 'offline') {
403
- this.selectedRoute = providerRoute();
404
- void this.execute(() => this.newSession());
557
+ void this.execute(() => this.applyRoute(providerRoute()));
405
558
  return;
406
559
  }
407
560
  const catalog = PI_PROVIDERS.find((p) => p.id === provider);
@@ -409,10 +562,87 @@ export class SalaamApp {
409
562
  value: m.id,
410
563
  label: m.name,
411
564
  description: m.id + ' · ' + m.contextWindow + ' context tokens',
412
- })), (model) => {
413
- this.selectedRoute = providerRoute(provider, model);
414
- void this.execute(() => this.newSession());
415
- });
565
+ })), (model) => void this.execute(() => this.applyRoute(providerRoute(provider, model))));
566
+ });
567
+ }
568
+ modeSession;
569
+ /** Phase 2b: the session's mode and full access, for the status line. */
570
+ async loadMode() {
571
+ if (!this.client.snapshot) {
572
+ this.view.mode = undefined;
573
+ return;
574
+ }
575
+ const current = await this.client.permissions();
576
+ this.view.mode =
577
+ current.configurable && current.coding
578
+ ? {
579
+ mode: current.policy.mode,
580
+ ...(current.policy.access ? { access: current.policy.access } : {}),
581
+ ...(current.command_sandbox ? { sandbox: current.command_sandbox } : {}),
582
+ }
583
+ : undefined;
584
+ this.tui.requestRender();
585
+ }
586
+ /** Shift+Tab: review → accept edits → auto. Full access and YOLO are kept as they are; each has
587
+ * its own command. */
588
+ async cycleMode() {
589
+ const session = this.client.snapshot?.id;
590
+ if (!session)
591
+ throw new Error('Open a session first.');
592
+ const current = await this.client.permissions();
593
+ if (!current.configurable || !current.coding)
594
+ throw new Error('Modes are for local coding sessions. No session or files were changed.');
595
+ await this.client.setPermissions(session, current.policy.revision, {
596
+ mode: nextMode(current.policy.mode),
597
+ paths: [],
598
+ ...(current.policy.access ? { access: current.policy.access } : {}),
599
+ });
600
+ await this.loadMode();
601
+ }
602
+ async setAccess(access) {
603
+ const session = this.client.snapshot.id;
604
+ const current = await this.client.permissions();
605
+ await this.client.setPermissions(session, current.policy.revision, {
606
+ mode: current.policy.mode,
607
+ paths: current.policy.paths,
608
+ ...(access ? { access } : {}),
609
+ });
610
+ await this.loadMode();
611
+ }
612
+ /** Full access on (with a warning and a confirm) or off (at once). From YOLO it steps down at once. */
613
+ async fullAccess() {
614
+ const session = this.client.snapshot?.id;
615
+ if (!session)
616
+ throw new Error('Open a session first.');
617
+ const current = await this.client.permissions();
618
+ if (!current.configurable || !current.coding)
619
+ throw new Error('Full access is for local coding sessions. Nothing was changed.');
620
+ if (current.policy.access === 'full')
621
+ return this.setAccess(undefined);
622
+ if (current.full_access_allowed === false)
623
+ throw new Error('The operator does not allow full access on this host. Commands stay in the OS sandbox.');
624
+ if (current.policy.access === 'yolo')
625
+ return this.setAccess('full');
626
+ this.confirm('Turn on full access?', 'Later commands in this session run WITHOUT the OS sandbox: they can read and change any file your user can, and reach the network directly. They run without asking, except commands the danger check calls dangerous (force pushes, deletes, publishing, credentials, anything it cannot read), which still ask and say why. Run /full-access again to turn it off.', 'turn on full access', () => this.setAccess('full'));
627
+ }
628
+ /** YOLO on only after the person types "yolo"; off at once. Never in the Shift+Tab cycle. */
629
+ async yolo() {
630
+ const session = this.client.snapshot?.id;
631
+ if (!session)
632
+ throw new Error('Open a session first.');
633
+ const current = await this.client.permissions();
634
+ if (!current.configurable || !current.coding)
635
+ throw new Error('YOLO is for local coding sessions. Nothing was changed.');
636
+ if (current.policy.access === 'yolo')
637
+ return this.setAccess(undefined);
638
+ if (current.yolo_allowed === false)
639
+ throw new Error('YOLO is not allowed for this session: it is only for your own desk sessions on a host whose operator allows it.');
640
+ this.taskInput('Turn on YOLO? Type yolo to confirm', '', 'Type yolo', 'NO approvals at all: every edit and command runs at once, without the OS sandbox, including force pushes, deletes and publishing. Each one is recorded. /yolo again turns it off.', (value) => {
641
+ if (value.trim() !== 'yolo') {
642
+ this.client.report('YOLO stays off: the confirmation was not "yolo".');
643
+ return;
644
+ }
645
+ void this.execute(() => this.setAccess('yolo'));
416
646
  });
417
647
  }
418
648
  showText(title, text) {
@@ -430,7 +660,39 @@ export class SalaamApp {
430
660
  return;
431
661
  }
432
662
  if (command === 'help') {
433
- this.showText('Salaam commands', slashHelp(args || undefined));
663
+ const custom = args ? [] : this.customCommands();
664
+ const problems = args ? [] : this.customCommandProblems;
665
+ this.showText('Salaam commands', slashHelp(args || undefined) +
666
+ (custom.length
667
+ ? '\n\nCustom commands (write a prompt only):\n' +
668
+ custom
669
+ .map((c) => `/${c.name}${c.argumentHint ? ' ' + c.argumentHint : ''} — ${c.description} [${c.scope}]`)
670
+ .join('\n')
671
+ : '') +
672
+ (problems.length ? '\n\nSkipped command files:\n' + problems.join('\n') : ''));
673
+ return;
674
+ }
675
+ if (command === 'hooks') {
676
+ if (args === 'revoke') {
677
+ await this.client.revokeHooks();
678
+ return;
679
+ }
680
+ const { sources } = await this.client.hooks();
681
+ if (args === 'trust') {
682
+ const repo = sources.find((s) => s.scope === 'repo');
683
+ if (!repo?.digest)
684
+ throw new Error('This workspace has no readable .salaam/hooks.json.');
685
+ if (repo.trusted) {
686
+ this.showText('Hooks', 'This workspace’s hooks are already trusted as they are.');
687
+ return;
688
+ }
689
+ this.confirm('Trust this workspace’s hooks?', describeHooks([repo]) +
690
+ '\n\nThese commands will run on your machine, in this session’s sandbox, whenever their event happens. Trust covers this exact content; any change needs trusting again.', 'Trust these hooks', () => this.client.trustHooks(repo.digest));
691
+ return;
692
+ }
693
+ if (args)
694
+ throw new Error('Use /hooks, /hooks trust or /hooks revoke.');
695
+ this.showText('Hooks', describeHooks(sources));
434
696
  return;
435
697
  }
436
698
  if (command === 'keys') {
@@ -444,9 +706,8 @@ export class SalaamApp {
444
706
  }
445
707
  const slash = args.indexOf('/');
446
708
  if (slash <= 0)
447
- throw new Error('Use /model provider/model. The choice creates a new session.');
448
- this.selectedRoute = providerRoute(args.slice(0, slash), args.slice(slash + 1));
449
- await this.newSession();
709
+ throw new Error('Use /model provider/model.');
710
+ await this.applyRoute(providerRoute(args.slice(0, slash), args.slice(slash + 1)));
450
711
  return;
451
712
  }
452
713
  if (command === 'provider') {
@@ -455,17 +716,13 @@ export class SalaamApp {
455
716
  return;
456
717
  }
457
718
  if (args === 'offline') {
458
- this.selectedRoute = providerRoute();
459
- await this.newSession();
719
+ await this.applyRoute(providerRoute());
460
720
  return;
461
721
  }
462
722
  const provider = PI_PROVIDERS.find((p) => p.id === args);
463
723
  if (!provider)
464
724
  throw new Error('Unknown provider. Use /provider to browse the pinned catalog.');
465
- this.choice('Model · ' + provider.name, provider.getModels().map((m) => ({ value: m.id, label: m.name, description: m.id })), (model) => {
466
- this.selectedRoute = providerRoute(args, model);
467
- void this.execute(() => this.newSession());
468
- });
725
+ this.choice('Model · ' + provider.name, provider.getModels().map((m) => ({ value: m.id, label: m.name, description: m.id })), (model) => void this.execute(() => this.applyRoute(providerRoute(args, model))));
469
726
  return;
470
727
  }
471
728
  if (command === 'new' || command === 'coding') {
@@ -494,7 +751,7 @@ export class SalaamApp {
494
751
  this.choice('Settings', [
495
752
  { value: 'permissions', label: 'Permissions · inspect, review or scoped edits' },
496
753
  { value: 'progression', label: 'Progression · manual, bounded tasks or schedules' },
497
- { value: 'model', label: 'Provider and model (new session)' },
754
+ { value: 'model', label: 'Provider and model' },
498
755
  { value: 'theme', label: 'Switch Night / Paper' },
499
756
  { value: 'rail', label: 'Toggle workspace rail' },
500
757
  { value: 'keys', label: 'Keyboard shortcuts' },
@@ -526,6 +783,10 @@ export class SalaamApp {
526
783
  }
527
784
  if (!snapshot)
528
785
  throw new Error('Choose a session with /sessions or /new first.');
786
+ if (command === 'rewind') {
787
+ await this.rewind(args);
788
+ return;
789
+ }
529
790
  if (command === 'undo') {
530
791
  if (args === 'reconcile') {
531
792
  await this.client.reconcileUndo();
@@ -551,6 +812,39 @@ export class SalaamApp {
551
812
  this.confirm('Fork session', 'Create an independent conversation from the captured history? Both sessions share the same workspace; files are not cloned. Pending approvals and task authority are not copied.', 'Fork conversation', () => this.client.fork());
552
813
  return;
553
814
  }
815
+ if (command === 'commit' || command === 'branch' || command === 'pr') {
816
+ if (!snapshot.capabilities.includes('local-coding-reviewed'))
817
+ throw new Error(`/${command} runs git. Start a coding session with /coding first.`);
818
+ const root = snapshot.cwd;
819
+ let instruction;
820
+ if (command === 'commit')
821
+ instruction = commitInstruction({ ...loadGitPrefs(), session: snapshot.id });
822
+ else if (command === 'branch') {
823
+ if (!args)
824
+ throw new Error('Usage: /branch <name>');
825
+ instruction = branchInstruction(args);
826
+ }
827
+ else {
828
+ const branch = currentBranch(root), base = args || defaultBase(root);
829
+ if (!branch)
830
+ throw new Error('/pr needs a checked-out branch, not a detached HEAD.');
831
+ if (branch === base)
832
+ throw new Error(`You are on ${base}. Create a branch first with /branch <name>.`);
833
+ if (!validBranchName(base))
834
+ throw new Error(`"${base}" is not a valid branch name.`);
835
+ instruction = prInstruction({ tool: forgeTool(root), branch, base });
836
+ }
837
+ await this.client.submit(instruction);
838
+ this.setTab('conversation');
839
+ return;
840
+ }
841
+ if (command === 'init') {
842
+ if (!snapshot.capabilities.includes('local-coding-reviewed'))
843
+ throw new Error('/init writes a file. Start a coding session with /coding first.');
844
+ await this.client.submit(INIT_INSTRUCTION);
845
+ this.setTab('conversation');
846
+ return;
847
+ }
554
848
  if (command === 'compact') {
555
849
  await this.client.compact();
556
850
  return;
@@ -564,7 +858,10 @@ export class SalaamApp {
564
858
  return;
565
859
  }
566
860
  if (command === 'diff') {
567
- if (snapshot.capabilities.includes('local-workspace-read')) {
861
+ // Coding sessions review their diff file by file in Changes (Phase 3a).
862
+ if (snapshot.capabilities.includes('local-coding-reviewed'))
863
+ this.setTab('changes');
864
+ else if (snapshot.capabilities.includes('local-workspace-read')) {
568
865
  const diff = await call(this.client.socket, `/v1/sessions/${snapshot.id}/local-diff`);
569
866
  this.showText('Local Git changes', diff.text);
570
867
  }
@@ -580,17 +877,68 @@ export class SalaamApp {
580
877
  this.taskActions();
581
878
  return;
582
879
  }
880
+ if (command === 'classifier') {
881
+ const path = `/v1/sessions/${snapshot.id}/danger-classifier`;
882
+ const set = async (choice) => {
883
+ const v = await call(this.client.socket, path, {
884
+ ...this.client.actor(),
885
+ choice,
886
+ });
887
+ this.client.notice = classifierNotice(v);
888
+ this.tui.requestRender();
889
+ };
890
+ const want = args.trim();
891
+ if (want === 'on') {
892
+ const v = await call(this.client.socket, path, {
893
+ ...this.client.actor(),
894
+ enabled: true,
895
+ });
896
+ this.client.notice = classifierNotice(v);
897
+ return;
898
+ }
899
+ if (want) {
900
+ await set(want);
901
+ return;
902
+ }
903
+ const v = await call(this.client.socket, path);
904
+ const items = classifierItems(v);
905
+ if (!items.length) {
906
+ this.client.notice = classifierNotice(v);
907
+ return;
908
+ }
909
+ this.choice('Command reviewer · ' + classifierNotice(v).split('.')[0], items, (choice) => {
910
+ void this.execute(() => set(choice));
911
+ });
912
+ return;
913
+ }
914
+ if (command === 'jobs') {
915
+ const base = `/v1/sessions/${snapshot.id}/local-tasks`;
916
+ const stop = /^stop\s+(\S+)$/.exec(args.trim());
917
+ if (stop) {
918
+ await call(this.client.socket, `${base}/${stop[1]}/stop`, {});
919
+ this.client.notice = 'Stopping background command ' + stop[1];
920
+ return;
921
+ }
922
+ const { tasks } = await call(this.client.socket, base);
923
+ const outputs = await Promise.all(tasks
924
+ .slice(0, 4)
925
+ .map((t) => call(this.client.socket, `${base}/${t.id}`)));
926
+ this.showText('Background commands', jobsText(tasks, outputs));
927
+ return;
928
+ }
583
929
  if (command === 'cancel') {
584
- const active = snapshot.attempts.filter((a) => ['accepted', 'dispatching', 'running'].includes(a.state));
930
+ const active = cancelChoices(snapshot);
585
931
  if (!active.length) {
586
932
  this.client.report('No active or queued instruction to cancel.');
587
933
  return;
588
934
  }
589
- const cancel = (id) => this.confirm('Cancel instruction', 'Cancel attempt ' + id + '? /exit would leave it running.', 'Cancel work', () => this.client.cancel(id));
935
+ const cancel = (id) => this.confirm('Cancel instruction', 'Cancel attempt ' +
936
+ shortId(id) +
937
+ '? Your typed text is kept. /exit would leave it running.', 'Cancel work', () => this.client.cancel(id), true);
590
938
  if (active.length === 1)
591
939
  cancel(active[0].id);
592
940
  else
593
- this.choice('Cancel an instruction', active.map((a) => ({ value: a.id, label: a.state + ' · ' + shortId(a.id) })), cancel);
941
+ this.choice('Cancel an instruction', active.map((a) => ({ value: a.id, label: a.label })), cancel);
594
942
  return;
595
943
  }
596
944
  if (command === 'steer') {
@@ -604,7 +952,22 @@ export class SalaamApp {
604
952
  await this.client.submit(args);
605
953
  return;
606
954
  }
607
- this.showText('Durable instruction queue', JSON.stringify(snapshot.submissions.filter((s) => ['accepted', 'dispatching', 'running'].includes(s.state)), null, 2));
955
+ const queued = queuedItems(snapshot);
956
+ if (!queued.length) {
957
+ this.client.report('Nothing is queued. Tab queues text for after the running turn.');
958
+ return;
959
+ }
960
+ this.choice('Queued instructions', queued.map((q) => ({ value: q.attempt, label: q.text.slice(0, 80) })), (attempt) => {
961
+ const item = queued.find((q) => q.attempt === attempt);
962
+ this.choice('Queued instruction', [
963
+ { value: 'edit', label: 'Edit', description: 'Move it back into the input' },
964
+ { value: 'remove', label: 'Remove', description: 'Cancel it before it runs' },
965
+ ], (choice) => void this.execute(async () => {
966
+ await this.client.cancel(item.attempt);
967
+ if (choice === 'edit')
968
+ this.editor.setText(item.text);
969
+ }));
970
+ });
608
971
  return;
609
972
  }
610
973
  if (command === 'plan' || command === 'review') {
@@ -618,6 +981,18 @@ export class SalaamApp {
618
981
  this.showText('Provider usage', JSON.stringify(this.client.usage ?? { status: 'No usage observations yet; this is not zero usage.' }, null, 2));
619
982
  return;
620
983
  }
984
+ if (command === 'mode') {
985
+ await this.cycleMode();
986
+ return;
987
+ }
988
+ if (command === 'full-access') {
989
+ await this.fullAccess();
990
+ return;
991
+ }
992
+ if (command === 'yolo') {
993
+ await this.yolo();
994
+ return;
995
+ }
621
996
  if (command === 'permissions') {
622
997
  await this.permissionControls();
623
998
  return;
@@ -700,14 +1075,11 @@ export class SalaamApp {
700
1075
  const current = snapshot.provider_route;
701
1076
  if (!current || current.provider === 'salaam-proof')
702
1077
  throw new Error('Choose a live provider/model first with /model.');
703
- const choose = (level) => {
704
- this.selectedRoute = providerRoute(current.provider, current.model, current.max_tokens, current.workspace_id, current.connection, level);
705
- return this.newSession(snapshot.capabilities.includes('local-coding-reviewed'));
706
- };
1078
+ const choose = (level) => this.applyRoute(providerRoute(current.provider, current.model, current.max_tokens, current.workspace_id, current.connection, level));
707
1079
  if (args)
708
1080
  await choose(args);
709
1081
  else
710
- this.choice('Reasoning · new session · current ' + (current.reasoning ?? 'automatic low'), REASONING_LEVELS.map((level) => ({ value: level, label: level })), (level) => void this.execute(() => choose(level)));
1082
+ this.choice('Reasoning · current ' + (current.reasoning ?? 'automatic low'), REASONING_LEVELS.map((level) => ({ value: level, label: level })), (level) => void this.execute(() => choose(level)));
711
1083
  return;
712
1084
  }
713
1085
  if (command === 'name') {
@@ -746,9 +1118,7 @@ export class SalaamApp {
746
1118
  }
747
1119
  async newSession(localCoding = false) {
748
1120
  await this.startService();
749
- this.showModal(new FormModal((localCoding
750
- ? 'New coding session · reviewed edits and unsandboxed commands · '
751
- : 'New session · ') +
1121
+ this.showModal(new FormModal((localCoding ? 'New coding session · reviewed edits and commands · ' : 'New session · ') +
752
1122
  (this.selectedRoute?.model ?? this.client.health?.provider_route.model ?? 'default'), this.options.cwd ?? this.client.snapshot?.cwd ?? process.cwd(), this.view.theme, !localCoding && !!this.client.health?.coding_root, () => this.tui.requestRender(), () => this.closeModal(), (cwd, coding) => {
753
1123
  this.closeModal();
754
1124
  void this.execute(async () => {
@@ -758,15 +1128,22 @@ export class SalaamApp {
758
1128
  }));
759
1129
  }
760
1130
  async sessions() {
1131
+ const { pickerOrder, sessionDescription } = await import('./session-pick.js');
761
1132
  await this.client.list();
1133
+ const now = Date.now();
762
1134
  this.choice('Your Salaam sessions', [
763
1135
  { value: 'new', label: '+ New session', description: 'Start a fresh workspace' },
764
- ...this.client.sessions.map((session) => ({
1136
+ ...pickerOrder(this.client.sessions, resolve(this.options.cwd ?? process.cwd())).map((session) => ({
765
1137
  value: session.id,
766
1138
  label: (this.client.names[session.id] ?? basename(session.cwd)) + ' ' + shortId(session.id),
767
- description: session.state + ' · ' + session.cwd,
1139
+ description: sessionDescription(session, now),
768
1140
  })),
769
1141
  ], (value) => {
1142
+ if (value === 'transcript') {
1143
+ this.view.transcript = !this.view.transcript;
1144
+ this.setTab('conversation');
1145
+ return;
1146
+ }
770
1147
  if (value === 'model')
771
1148
  this.modelPicker();
772
1149
  else if (value === 'help')
@@ -785,10 +1162,62 @@ export class SalaamApp {
785
1162
  });
786
1163
  });
787
1164
  }
1165
+ commandCache;
1166
+ customCommandProblems = [];
1167
+ /** This workspace's and your own custom commands, reread at most every two seconds. */
1168
+ customCommands() {
1169
+ const root = this.client.snapshot?.cwd ?? this.options.cwd ?? process.cwd();
1170
+ const cached = this.commandCache;
1171
+ if (cached && cached.root === root && Date.now() - cached.at < 2000)
1172
+ return cached.commands;
1173
+ const { commands, problems } = loadCustomCommands(root, this.options.commandsDir);
1174
+ this.customCommandProblems = problems;
1175
+ this.commandCache = { root, at: Date.now(), commands };
1176
+ return commands;
1177
+ }
1178
+ refreshCommandCompletions() {
1179
+ const root = this.client.snapshot?.cwd ?? this.options.cwd ?? process.cwd();
1180
+ this.editor.setAutocompleteProvider(new MentionAutocompleteProvider([
1181
+ ...SLASH_COMPLETIONS,
1182
+ ...this.customCommands().map((c) => ({
1183
+ name: c.name,
1184
+ description: `${c.description} [${c.scope}]`,
1185
+ })),
1186
+ ], root));
1187
+ }
788
1188
  async submit() {
789
1189
  const text = this.editor.getExpandedText(), session = this.client.snapshot?.id, continuation = this.view.continuation;
790
1190
  if (!text.trim())
791
1191
  return;
1192
+ const shell = shellPassthrough(text);
1193
+ if (shell) {
1194
+ if (!session)
1195
+ throw new Error('Create or choose a session with ctrl+n or ctrl+p first.');
1196
+ if (!interactiveCoding(this.client.snapshot))
1197
+ throw new Error('!command works in local coding sessions (salaam code).');
1198
+ await this.deliver(shell, session, continuation, false, text);
1199
+ return;
1200
+ }
1201
+ // A custom command only writes a prompt; built-in names never reach here (they are refused at
1202
+ // load), and the expanded prompt goes through every check an ordinary message does.
1203
+ const custom = text.trim().startsWith('/')
1204
+ ? matchCustomCommand(text, this.customCommands())
1205
+ : null;
1206
+ if (custom) {
1207
+ if (!session)
1208
+ throw new Error('Create or choose a session with ctrl+n or ctrl+p first.');
1209
+ const prompt = expandCustomCommand(custom.command, custom.args);
1210
+ if (Buffer.byteLength(prompt) > 65536)
1211
+ throw new Error(`/${custom.command.name} expands past 64 KiB. Shorten the command file.`);
1212
+ const secrets = extractMentions(prompt).filter(isSensitivePath);
1213
+ const send = (sensitive) => this.deliver(prompt, session, continuation, sensitive, text);
1214
+ if (secrets.length) {
1215
+ this.confirm('Attach secret-looking files?', `/${custom.command.name} mentions ${secrets.map((s) => '@' + s).join(', ')}, which will be sent to the model in full.`, 'Attach and send', () => send(true));
1216
+ return;
1217
+ }
1218
+ await send(false);
1219
+ return;
1220
+ }
792
1221
  const command = parseSlashCommand(text);
793
1222
  if (command) {
794
1223
  await this.runSlashCommand(command.name, command.args);
@@ -802,9 +1231,20 @@ export class SalaamApp {
802
1231
  throw new Error('Create or choose a session with ctrl+n or ctrl+p first.');
803
1232
  if (Buffer.byteLength(text) > 65536)
804
1233
  throw new Error('The composer accepts at most 64 KiB. Use an artifact for longer input.');
805
- await this.client.submit(text, continuation);
806
- this.editor.addToHistory(text);
807
- if (this.client.snapshot?.id === session && this.editor.getExpandedText() === text) {
1234
+ const secrets = extractMentions(text).filter(isSensitivePath);
1235
+ if (secrets.length) {
1236
+ this.confirm('Attach secret-looking files?', `${secrets.map((s) => '@' + s).join(', ')} will be sent to the model in full.`, 'Attach and send', () => this.deliver(text, session, continuation, true));
1237
+ return;
1238
+ }
1239
+ await this.deliver(text, session, continuation, false);
1240
+ }
1241
+ async deliver(text, session, continuation, confirmSensitive,
1242
+ /** What was typed, when it differs from what is sent (a custom command). */
1243
+ typed = text) {
1244
+ await this.client.submit(text, continuation, confirmSensitive);
1245
+ this.editor.addToHistory(typed);
1246
+ this.sent.add(typed);
1247
+ if (this.client.snapshot?.id === session && this.editor.getExpandedText() === typed) {
808
1248
  this.editor.setText('');
809
1249
  this.drafts.delete(session);
810
1250
  }
@@ -817,9 +1257,114 @@ export class SalaamApp {
817
1257
  this.view.tab = tab;
818
1258
  this.resetScroll();
819
1259
  if (tab === 'changes')
820
- void this.execute(() => this.loadDiff(this.view.diffOffset));
1260
+ void this.execute(() => this.loadChanges());
821
1261
  this.tui.requestRender();
822
1262
  }
1263
+ /** A local coding session reviews its folder against the review base; a coding workspace
1264
+ * keeps its guarded diff. */
1265
+ loadChanges() {
1266
+ return interactiveCoding(this.client.snapshot)
1267
+ ? this.loadReview()
1268
+ : this.loadDiff(this.view.diffOffset);
1269
+ }
1270
+ async loadReview(file = this.view.review?.file ?? 0) {
1271
+ const id = this.client.snapshot?.id;
1272
+ const data = await this.client.changeReview();
1273
+ if (this.client.snapshot?.id !== id)
1274
+ return;
1275
+ const index = data?.files.length ? Math.min(Math.max(0, file), data.files.length - 1) : 0;
1276
+ const path = data?.files[index]?.path;
1277
+ const hunks = data && path ? await this.client.changeHunks(data, path) : undefined;
1278
+ this.view.review = { data, file: index, hunks, hunk: -1 };
1279
+ this.document.invalidate();
1280
+ this.tui.requestRender();
1281
+ }
1282
+ /** Changes-tab keys: alt+left/right file, alt+up/down hunk; with an empty editor, a accepts,
1283
+ * r reverts and i starts a follow-up instruction about the selection. */
1284
+ reviewKey(data) {
1285
+ const review = this.view.review;
1286
+ if (!review?.data?.files.length)
1287
+ return;
1288
+ const file = review.data.files[review.file];
1289
+ if (matchesKey(data, REVIEW_KEYS.nextFile.key) ||
1290
+ matchesKey(data, REVIEW_KEYS.previousFile.key)) {
1291
+ const next = review.file + (matchesKey(data, REVIEW_KEYS.nextFile.key) ? 1 : -1);
1292
+ if (next >= 0 && next < review.data.files.length)
1293
+ void this.execute(() => this.loadReview(next));
1294
+ return { consume: true };
1295
+ }
1296
+ if (matchesKey(data, REVIEW_KEYS.nextHunk.key) ||
1297
+ matchesKey(data, REVIEW_KEYS.previousHunk.key)) {
1298
+ const count = review.hunks?.length ?? 0;
1299
+ review.hunk = Math.max(-1, Math.min(count - 1, review.hunk + (matchesKey(data, REVIEW_KEYS.nextHunk.key) ? 1 : -1)));
1300
+ this.document.invalidate();
1301
+ this.tui.requestRender();
1302
+ return { consume: true };
1303
+ }
1304
+ if (this.editor.getExpandedText() !== '')
1305
+ return;
1306
+ const hunk = review.hunk >= 0 ? review.hunks?.[review.hunk] : undefined;
1307
+ const what = file.path + (hunk ? ` (hunk ${review.hunk + 1})` : '');
1308
+ const act = (action) => async () => {
1309
+ const data = await this.client.changeAct(review.data, action, file.path, hunk);
1310
+ this.view.review = { data, file: review.file, hunk: -1 };
1311
+ await this.loadReview(review.file);
1312
+ this.client.report((action === 'accept' ? 'Accepted ' : 'Reverted ') + what + '.');
1313
+ };
1314
+ if (data === REVIEW_KEYS.accept.key) {
1315
+ void this.execute(act('accept'));
1316
+ return { consume: true };
1317
+ }
1318
+ if (data === REVIEW_KEYS.revert.key) {
1319
+ this.confirm('Revert ' + what, (hunk ? 'Puts this hunk back' : 'Puts this file back') +
1320
+ ' the way it was at the review base. Only the reviewed version is written; if the file changed since, nothing is written and you review again.', 'Revert', act('revert'));
1321
+ return { consume: true };
1322
+ }
1323
+ if (data === REVIEW_KEYS.instruct.key) {
1324
+ this.setTab('conversation');
1325
+ this.editor.setText(`In ${file.path}${hunk ? ` (the change at ${hunk.text.split('\n')[0]})` : ''}: `);
1326
+ return { consume: true };
1327
+ }
1328
+ return;
1329
+ }
1330
+ /** /rewind [turn]: choose a turn and what to restore, review, then confirm once. */
1331
+ async rewind(args) {
1332
+ const points = await this.client.checkpoints();
1333
+ if (!points.length)
1334
+ throw new Error('No turn has been checkpointed in this session yet. A checkpoint is taken as each turn starts.');
1335
+ const choose = (attempt) => this.choice('Rewind · what to restore', [
1336
+ {
1337
+ value: 'both',
1338
+ label: 'Files and conversation',
1339
+ description: 'Restore files and continue from before this turn in a new session',
1340
+ },
1341
+ {
1342
+ value: 'files',
1343
+ label: 'Files only',
1344
+ description: 'Restore files; keep the conversation',
1345
+ },
1346
+ {
1347
+ value: 'conversation',
1348
+ label: 'Conversation only',
1349
+ description: 'Continue from before this turn in a new session; files stay',
1350
+ },
1351
+ ], (mode) => void this.execute(async () => {
1352
+ const review = await this.client.rewindReview(attempt, mode);
1353
+ this.confirm(`Rewind to before turn ${review.turn}`, rewindSummary(review), 'Rewind', async () => {
1354
+ const text = await this.client.rewind(review);
1355
+ if (review.mode !== 'files')
1356
+ this.editor.setText(text);
1357
+ });
1358
+ }));
1359
+ if (args) {
1360
+ const found = points.find((p) => String(p.turn) === args.trim());
1361
+ if (!found)
1362
+ throw new Error(`No checkpoint for turn ${args.trim()}. /rewind lists them.`);
1363
+ choose(found.attempt_id);
1364
+ return;
1365
+ }
1366
+ this.choice('Rewind to before which turn?', rewindItems(points), choose);
1367
+ }
823
1368
  resetScroll() {
824
1369
  if (this.view.tab === 'conversation' || this.view.tab === 'commands')
825
1370
  this.scroll.scrollToEnd();
@@ -829,7 +1374,7 @@ export class SalaamApp {
829
1374
  async refresh() {
830
1375
  await this.client.refresh();
831
1376
  if (this.view.tab === 'changes')
832
- await this.loadDiff(this.view.diffOffset);
1377
+ await this.loadChanges();
833
1378
  }
834
1379
  async loadDiff(offset) {
835
1380
  if (this.diffBusy)
@@ -865,7 +1410,12 @@ export class SalaamApp {
865
1410
  const open = (id) => void this.execute(async () => {
866
1411
  const approval = approvals.find((a) => a.id === id);
867
1412
  const reviewed = await this.client.review(approval);
868
- this.showApproval(reviewed.approval, reviewed.operation);
1413
+ // Desk sessions can approve for the session or always; strict work only ever approves once.
1414
+ const rules = await this.client.approvalRules().catch(() => ({ applies: false }));
1415
+ const onceOnly = rules.applies
1416
+ ? onceOnlyReason(String(reviewed.operation.value))
1417
+ : undefined;
1418
+ this.showApproval(reviewed.approval, reviewed.operation, rules.applies && !onceOnly, onceOnly, rules.applies);
869
1419
  });
870
1420
  if (approvals.length === 1)
871
1421
  open(approvals[0].id);
@@ -877,11 +1427,121 @@ export class SalaamApp {
877
1427
  description: shortId(approval.id) + ' · revision ' + approval.revision,
878
1428
  })), open);
879
1429
  }
880
- showApproval(approval, operation) {
1430
+ /** /permissions → Approval rules: list them, remove one, or add one in plain words. */
1431
+ async approvalRules() {
1432
+ const { applies, rules, problems } = await this.client.approvalRules();
1433
+ if (!applies) {
1434
+ this.client.report('Approval rules apply only to desk coding sessions.');
1435
+ return;
1436
+ }
1437
+ const describe = (r) => `${r.effect} ${r.tool} ${r.command ?? (r.paths ?? []).join(', ')}`.trim();
1438
+ this.choice('Approval rules · deny beats ask beats allow · unmatched requests ask', [
1439
+ {
1440
+ value: 'add',
1441
+ label: 'Add a rule',
1442
+ description: 'e.g. allow command npm test * · deny edit src/secrets/**',
1443
+ },
1444
+ ...rules.map((r) => ({
1445
+ value: 'rule:' + r.id,
1446
+ label: describe(r),
1447
+ description: r.source === 'user'
1448
+ ? 'From your permissions.json; edit that file to change it'
1449
+ : r.source === 'repo'
1450
+ ? 'Always, in this repo · choose to remove'
1451
+ : 'This session · choose to remove',
1452
+ })),
1453
+ ...problems.map((p, i) => ({ value: 'problem:' + i, label: 'Problem', description: p })),
1454
+ ], (value) => {
1455
+ if (value === 'add') {
1456
+ this.taskInput('New approval rule', '', 'effect tool words-or-paths', 'allow|ask|deny, then command or edit, then command words (a final * takes any further words) or comma-separated path globs. Example: allow command git status', (text) => {
1457
+ const [effect, tool, ...rest] = text.trim().split(/\s+/);
1458
+ const target = rest.join(' ');
1459
+ const rule = tool === 'edit'
1460
+ ? {
1461
+ effect,
1462
+ tool,
1463
+ paths: target
1464
+ .split(',')
1465
+ .map((p) => p.trim())
1466
+ .filter(Boolean),
1467
+ }
1468
+ : { effect, tool, command: target };
1469
+ this.choice('Keep this rule for…', [
1470
+ {
1471
+ value: 'session',
1472
+ label: 'This session',
1473
+ description: 'Gone when the session ends',
1474
+ },
1475
+ {
1476
+ value: 'repo',
1477
+ label: 'This repo',
1478
+ description: "Every desk session here; kept in Salaam's state",
1479
+ },
1480
+ ], (scope) => void this.execute(() => this.client.addRule(scope, rule)));
1481
+ });
1482
+ return;
1483
+ }
1484
+ if (!value.startsWith('rule:'))
1485
+ return;
1486
+ const rule = rules.find((r) => 'rule:' + r.id === value);
1487
+ if (rule.source === 'user') {
1488
+ this.client.report('This rule comes from your permissions.json; edit that file to change it.');
1489
+ return;
1490
+ }
1491
+ this.confirm('Remove this rule?', describe(rule), 'remove', () => this.client.removeRule(rule.id));
1492
+ });
1493
+ }
1494
+ showApproval(approval, operation, offerRules = false, onceOnly, desk = false) {
881
1495
  this.showModal(new ApprovalModal(approval, operation, this.view.theme, () => this.terminal.rows, () => this.client.snapshot?.approvals.find((a) => a.id === approval.id), () => this.tui.requestRender(), () => this.closeModal(), (decision) => {
882
1496
  this.closeModal();
883
- void this.execute(() => this.client.decide(approval, decision));
884
- }), 86);
1497
+ if (decision === 'approve' || decision === 'deny') {
1498
+ void this.execute(() => this.client.decide(approval, decision));
1499
+ return;
1500
+ }
1501
+ if (decision === 'deny-feedback') {
1502
+ // Deny, then tell the agent why in its own turn, the way steering reaches it.
1503
+ this.taskInput('Deny and tell it why', '', 'Why not, or what to do instead', 'One line. The request is denied and this reaches the agent as feedback.', (why) => void this.execute(async () => {
1504
+ await this.client.decide(approval, 'deny');
1505
+ const text = why.trim();
1506
+ if (!text)
1507
+ return;
1508
+ // The turn has usually moved on by now; if it ended, put the reason in the
1509
+ // composer instead of losing it.
1510
+ const feedback = `I denied that request (${safeText(operation.target)}). ${text}`;
1511
+ try {
1512
+ await this.client.steer(feedback);
1513
+ }
1514
+ catch {
1515
+ this.editor.setText(feedback);
1516
+ this.client.report('Denied. The turn had ended, so your reason is in the composer; press enter to send it.');
1517
+ }
1518
+ }));
1519
+ return;
1520
+ }
1521
+ // The rule is the exact request just reviewed: its command words or its edited paths.
1522
+ // If it cannot become a rule (shell features, odd words), nothing is approved; say why.
1523
+ const approveWith = (scope) => async () => {
1524
+ // While the approval is pending the status line shows it, not a notice, so a
1525
+ // refused rule is shown where it cannot be missed.
1526
+ try {
1527
+ await this.client.ruleFromOperation(operation.id, scope);
1528
+ }
1529
+ catch (error) {
1530
+ this.showText('Not saved as a rule', `${error instanceof Error ? error.message : String(error)}\n\nNothing was approved. Press ctrl+o to approve it once instead.`);
1531
+ return;
1532
+ }
1533
+ await this.client.decide(approval, 'approve');
1534
+ this.client.report(scope === 'session'
1535
+ ? 'Approved, and the same request will be approved for the rest of this session.'
1536
+ : 'Approved, and the same request will always be approved in this repo. Remove it in /permissions.');
1537
+ };
1538
+ if (decision === 'approve-session')
1539
+ void this.execute(approveWith('session'));
1540
+ else
1541
+ this.confirm('Always allow in this repo?', `${safeText(operation.target)}
1542
+
1543
+ This exact request will be approved without asking in every desk session on this repository, until you remove it in /permissions. It is kept in Salaam's state, not in the repository.`, 'always allow', approveWith('repo'));
1544
+ }, offerRules, onceOnly, { desk, reason: approvalReason(String(operation.value), this.view.mode?.mode) }), 86);
885
1545
  }
886
1546
  taskInput(title, initial, field, hint, submit) {
887
1547
  this.showModal(new FormModal(title, initial, this.view.theme, false, () => this.tui.requestRender(), () => this.closeModal(), (value) => {
@@ -909,10 +1569,15 @@ export class SalaamApp {
909
1569
  },
910
1570
  ...(current.coding
911
1571
  ? [
1572
+ {
1573
+ value: 'rules',
1574
+ label: 'Approval rules',
1575
+ description: 'Allow, ask or deny by command words and path globs',
1576
+ },
912
1577
  {
913
1578
  value: 'review',
914
1579
  label: 'Review every mutation',
915
- description: 'Explicit approval for each edit and unsandboxed shell command',
1580
+ description: 'Explicit approval for each edit and shell command',
916
1581
  },
917
1582
  {
918
1583
  value: 'scoped-edit',
@@ -922,11 +1587,15 @@ export class SalaamApp {
922
1587
  ]
923
1588
  : []),
924
1589
  ], (mode) => {
1590
+ if (mode === 'rules') {
1591
+ void this.execute(() => this.approvalRules());
1592
+ return;
1593
+ }
925
1594
  if (mode === 'inspect-current') {
926
1595
  this.showText('Permissions and execution boundary', JSON.stringify(current, null, 2));
927
1596
  return;
928
1597
  }
929
- const apply = (paths) => this.showModal(new TextModal('Review permission policy', `Session ${session}\nWorkspace ${current.policy.root}\nMode ${mode}\nAuto-edit paths: ${paths.join(', ') || '(none)'}\nAll shell commands still require approval. This is not an OS sandbox. Progression and budgets remain unchanged. Already-running commands are not stopped.`, this.view.theme, () => this.terminal.rows, () => this.tui.requestRender(), () => this.closeModal(), undefined, () => {
1598
+ const apply = (paths) => this.showModal(new TextModal('Review permission policy', `Session ${session}\nWorkspace ${current.policy.root}\nMode ${mode}\nAuto-edit paths: ${paths.join(', ') || '(none)'}\nCommands ask unless an approval rule or the session mode allows them (auto allows only sandboxed commands; full access allows all but dangerous ones; YOLO allows everything). Commands run in the host OS sandbox unless full access or YOLO is on. Progression and budgets remain unchanged. Already-running commands are not stopped.`, this.view.theme, () => this.terminal.rows, () => this.tui.requestRender(), () => this.closeModal(), undefined, () => {
930
1599
  this.closeModal();
931
1600
  void this.execute(() => this.client.setPermissions(session, current.policy.revision, {
932
1601
  mode: mode,
@@ -1311,7 +1980,11 @@ export class SalaamApp {
1311
1980
  label: 'Operator inbox',
1312
1981
  description: 'Shared durable attention and results',
1313
1982
  },
1314
- { value: 'model', label: 'Choose model and provider', description: '/model · new session' },
1983
+ {
1984
+ value: 'model',
1985
+ label: 'Choose model and provider',
1986
+ description: '/model · switches this session',
1987
+ },
1315
1988
  { value: 'help', label: 'Slash commands', description: '/help' },
1316
1989
  { value: 'new', label: 'New session', description: 'ctrl+n' },
1317
1990
  {
@@ -1331,8 +2004,13 @@ export class SalaamApp {
1331
2004
  label: 'Job actions',
1332
2005
  description: 'Bound one-shot admission and durable occurrence recovery',
1333
2006
  },
1334
- { value: 'changes', label: 'Review changes', description: 'ctrl+g · guarded workspace diff' },
2007
+ { value: 'changes', label: 'Review changes', description: 'alt+g · guarded workspace diff' },
1335
2008
  { value: 'commands', label: 'Commands and output', description: 'ctrl+t' },
2009
+ {
2010
+ value: 'transcript',
2011
+ label: 'Full transcript',
2012
+ description: 'ctrl+x · every tool call and its output',
2013
+ },
1336
2014
  {
1337
2015
  value: 'context',
1338
2016
  label: 'Session context',
@@ -1384,11 +2062,11 @@ export class SalaamApp {
1384
2062
  label: 'Cancel current work',
1385
2063
  description: 'Explicitly cancel a running instruction',
1386
2064
  },
1387
- { value: 'refresh', label: 'Refresh connection', description: 'ctrl+r' },
2065
+ { value: 'refresh', label: 'Refresh connection', description: 'f5' },
1388
2066
  {
1389
2067
  value: 'editor',
1390
2068
  label: 'Edit prompt externally',
1391
- description: 'ctrl+e · VISUAL or EDITOR',
2069
+ description: 'ctrl+g · VISUAL or EDITOR',
1392
2070
  },
1393
2071
  {
1394
2072
  value: 'theme',
@@ -1591,44 +2269,16 @@ export class SalaamApp {
1591
2269
  this.showModal(new TextModal('Tool · ' + p.tool, `Attempt: ${p.attempt_id}\nTool call: ${p.tool_call_id}\nOutcome: ${outcome ? (outcome.payload.failed ? 'failed' : 'finished') : 'running or not captured'}\nInput${p.input_truncated ? ' (truncated; full digest recorded)' : ''}:\n${p.input ?? '(not captured)'}`, this.view.theme, () => this.terminal.rows, () => this.tui.requestRender(), () => this.closeModal()), 100);
1592
2270
  });
1593
2271
  }
2272
+ /** The keys for where you are: `?` on an empty composer, or F1. Built from tui-keymap.ts. */
1594
2273
  help() {
1595
- this.choice('Salaam · keyboard', [
1596
- {
1597
- value: 'close',
1598
- label: 'Enter · send the exact prompt',
1599
- description: 'The durable outbox saves it before delivery',
1600
- },
1601
- {
1602
- value: 'close',
1603
- label: 'Alt+Enter / Ctrl+J · new line',
1604
- description: 'Shift+Enter also works with supported keyboard protocols',
1605
- },
1606
- {
1607
- value: 'close',
1608
- label: 'Ctrl+K · all actions',
1609
- description: 'Ctrl+P sessions · Ctrl+N new session · Ctrl+O approvals',
1610
- },
1611
- {
1612
- value: 'close',
1613
- label: 'Tab · switch view',
1614
- description: 'Conversation · Changes · Commands · Context',
1615
- },
1616
- {
1617
- value: 'close',
1618
- label: 'PageUp / PageDown · scroll',
1619
- description: 'Mouse wheel also scrolls; Ctrl+Home/End jump to the top/bottom',
1620
- },
1621
- {
1622
- value: 'close',
1623
- label: 'Ctrl+E · external prompt editor',
1624
- description: 'Runs VISUAL or EDITOR with terminal modes restored',
1625
- },
1626
- {
1627
- value: 'close',
1628
- label: 'Ctrl+C · detach',
1629
- description: 'Work continues. Cancel and stop service are separate actions.',
1630
- },
1631
- ], () => { });
2274
+ const snapshot = this.client.snapshot;
2275
+ const sheet = keySheet({
2276
+ tab: this.view.tab,
2277
+ coding: interactiveCoding(snapshot),
2278
+ running: interactiveCoding(snapshot) && runningAttempt(snapshot) !== undefined,
2279
+ custom: this.customCommands(),
2280
+ });
2281
+ this.choice(sheet.title, sheet.rows.map((row) => ({ value: 'close', ...row })), () => { });
1632
2282
  }
1633
2283
  async editPrompt() {
1634
2284
  const [command, ...args] = editorArguments(process.env.VISUAL ?? process.env.EDITOR ?? 'vi');
@@ -1668,8 +2318,88 @@ export class SalaamApp {
1668
2318
  }
1669
2319
  }
1670
2320
  }
2321
+ /** Ctrl+C: stop a running coding turn, else clear the composer, else arm; twice within 2 s leaves. */
2322
+ ctrlC() {
2323
+ const attempt = interactiveCoding(this.client.snapshot)
2324
+ ? runningAttempt(this.client.snapshot)
2325
+ : undefined;
2326
+ const action = this.modal
2327
+ ? 'exit'
2328
+ : ctrlCAction({
2329
+ running: attempt !== undefined,
2330
+ text: this.editor.getExpandedText(),
2331
+ armedAt: this.ctrlCArmed,
2332
+ now: Date.now(),
2333
+ });
2334
+ this.ctrlCArmed = undefined;
2335
+ if (action === 'exit')
2336
+ this.finish();
2337
+ else if (action === 'interrupt')
2338
+ void this.execute(async () => {
2339
+ await this.client.cancel(attempt);
2340
+ this.client.report('Stopped the turn. Press ctrl+c twice to leave.');
2341
+ });
2342
+ else if (action === 'clear') {
2343
+ this.editor.setText('');
2344
+ this.client.report('Cleared. Press ctrl+c again to leave, or ctrl+- to undo.');
2345
+ }
2346
+ else {
2347
+ this.ctrlCArmed = Date.now();
2348
+ this.client.report('Press ctrl+c again to leave. Work continues in the service.');
2349
+ }
2350
+ }
2351
+ startSearch() {
2352
+ this.search = { query: '', index: this.sent.entries.length, draft: this.editor.getText() };
2353
+ this.client.report(searchPrompt('', undefined));
2354
+ }
2355
+ /** Keys while Ctrl+R search is open: type to narrow, ctrl+r for older, enter keeps, esc restores. */
2356
+ searchKey(data) {
2357
+ const s = this.search, entries = this.sent.entries;
2358
+ const show = () => {
2359
+ const match = s.index < entries.length ? entries[s.index] : undefined;
2360
+ if (match !== undefined)
2361
+ this.editor.setText(match);
2362
+ this.client.report(searchPrompt(s.query, match));
2363
+ };
2364
+ if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c') || matchesKey(data, 'ctrl+g')) {
2365
+ this.editor.setText(s.draft);
2366
+ this.search = undefined;
2367
+ this.client.report('');
2368
+ }
2369
+ else if (matchesKey(data, 'enter') || matchesKey(data, 'right') || matchesKey(data, 'left')) {
2370
+ this.search = undefined;
2371
+ this.client.report('');
2372
+ }
2373
+ else if (matchesKey(data, 'ctrl+r')) {
2374
+ const found = searchHistory(entries, s.query, s.index);
2375
+ if (found >= 0)
2376
+ s.index = found;
2377
+ show();
2378
+ }
2379
+ else if (matchesKey(data, 'backspace')) {
2380
+ s.query = s.query.slice(0, -1);
2381
+ s.index = searchHistory(entries, s.query, entries.length);
2382
+ if (s.index < 0)
2383
+ s.index = entries.length;
2384
+ if (s.query === '')
2385
+ this.editor.setText(s.draft);
2386
+ show();
2387
+ }
2388
+ else if (data.length === 1 && data >= ' ' && data !== '\x7f') {
2389
+ s.query += data;
2390
+ const found = searchHistory(entries, s.query, Math.min(s.index + 1, entries.length));
2391
+ s.index = found >= 0 ? found : entries.length;
2392
+ if (found < 0)
2393
+ this.editor.setText(s.draft);
2394
+ show();
2395
+ }
2396
+ this.tui.requestRender();
2397
+ return { consume: true };
2398
+ }
1671
2399
  async initialize() {
1672
2400
  await this.execute(async () => {
2401
+ if (this.options.start)
2402
+ return this.openHere(this.options.start);
1673
2403
  await this.client.list();
1674
2404
  if (this.options.session)
1675
2405
  await this.client.open(this.options.session, {
@@ -1684,6 +2414,29 @@ export class SalaamApp {
1684
2414
  }
1685
2415
  });
1686
2416
  }
2417
+ /** One command for this folder: start the service if needed, then continue the folder's
2418
+ * latest session, start a new coding session in it, or show the session list. */
2419
+ async openHere(start) {
2420
+ const { latestSessionFor, deskUsesAnthropic } = await import('./session-pick.js');
2421
+ await this.startService();
2422
+ if (deskUsesAnthropic(!!this.selectedRoute, this.client.health?.provider_route.provider, existsSync(credentialPath()) || existsSync(credentialPath() + '.json')))
2423
+ this.selectedRoute = providerRoute('anthropic');
2424
+ if (start === 'resume')
2425
+ return this.sessions();
2426
+ const cwd = resolve(this.options.cwd ?? process.cwd());
2427
+ const existing = start === 'continue' ? latestSessionFor(this.client.sessions, cwd) : undefined;
2428
+ if (existing)
2429
+ await this.client.open(existing, {
2430
+ observe: this.options.observe,
2431
+ takeover: this.options.takeover,
2432
+ });
2433
+ else {
2434
+ await this.client.create(cwd, false, this.selectedRoute, true);
2435
+ if (start === 'continue')
2436
+ this.client.notice = 'No earlier session in this folder, so this is a new one.';
2437
+ }
2438
+ this.setTab('conversation');
2439
+ }
1687
2440
  async run() {
1688
2441
  this.running = true;
1689
2442
  const close = () => {
@@ -1703,7 +2456,9 @@ export class SalaamApp {
1703
2456
  this.painter = setInterval(() => {
1704
2457
  if (this.client.preview ||
1705
2458
  this.client.snapshot?.approvals.some((a) => a.state === 'pending') ||
1706
- this.client.commands.some((run) => !commandTerminal(run.state)))
2459
+ this.client.commands.some((run) => !commandTerminal(run.state)) ||
2460
+ // The progress line counts seconds while a turn runs, so it never shows a stale state.
2461
+ this.client.snapshot?.attempts.some((a) => ['accepted', 'dispatching', 'running'].includes(a.state)))
1707
2462
  this.tui.requestRender();
1708
2463
  }, 1000);
1709
2464
  await this.initialize();
@@ -1719,6 +2474,8 @@ export class SalaamApp {
1719
2474
  process.off(signal, close);
1720
2475
  process.stdin.off('end', close);
1721
2476
  process.stdout.off('error', close);
2477
+ if (this.statusOn)
2478
+ this.herdr.release();
1722
2479
  await this.terminal.drainInput(150, 25);
1723
2480
  process.stdin.pause();
1724
2481
  process.stdin.unref();