@antst/dashi 0.1.0-alpha.8 → 0.1.0

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.
@@ -1,17 +1,28 @@
1
- import { createUserMessage } from '@deepseek-ai/dsh-llm';
1
+ import { basename } from 'node:path';
2
+ import { AuthorizationDeclinedError } from '@deepseek-ai/dsh-authorization';
3
+ import { parseCredentialKey } from '@deepseek-ai/dsh-credentials';
4
+ import { JobId } from '@deepseek-ai/dsh-jobs';
5
+ import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm';
6
+ import { expandAssistantStream } from '@deepseek-ai/dsh-llm/assistant-stream';
2
7
  import { SessionId, SessionSeq } from '@deepseek-ai/dsh-session';
3
- import { agentPresetOverlay, completionOptions, contextOverlay, currentModel, modelOverlay, permissionOverlay, searchOverlay, sessionOverlay, statusOverlay, } from './catalogs.js';
8
+ import { agentPresetOverlay, completionOptions, contextOverlay, currentModel, modelOverlay, permissionOverlay, searchOverlay, sessionStatusLine, sessionOverlay, skillOverlay, statusOverlay, usageOverlay, } from './catalogs.js';
4
9
  import { diffOverlay } from './diff-view.js';
5
10
  import { eventsFromRecords } from './history-records.js';
6
11
  import { admitDraftImages, encodeDraftImages, readDraftImages } from './image-input.js';
7
12
  import { contextPercent, jobViews, subagentViews } from './presentation.js';
8
- import { humanPrompt, humanPrompts, inheritedTurn, rewindActionOverlay, rewindOverlay, } from './rewind.js';
13
+ import { historyInput, historyInputs, humanPrompt, inheritedTurn, latestCompletedTurn, rewindActionOverlay, rewindOverlay, rewindModelSelection, } from './rewind.js';
9
14
  import { createToolPresenter } from './tool-presentation.js';
10
15
  import { quoteShellWord, runHumanShell } from './shell-command.js';
11
16
  import { markdownTranscript } from './session-export.js';
12
17
  import { isSessionId, sessionMatches, sessionNotFound } from './session-list.js';
13
18
  import { memoryOverlay } from './memory.js';
14
- import { foldCells, pendingShellCells } from './transcript.js';
19
+ import { foldAssistantStream, foldCells, pendingShellCells } from './transcript.js';
20
+ export class LaunchArgumentError extends Error {
21
+ }
22
+ function starterAgents(cwd) {
23
+ return `# ${basename(cwd)}\n\n## Working agreement\n\n- Add project-specific architecture guidance.\n- Add required build, test, and lint commands.\n- Add repository conventions and constraints.\n`;
24
+ }
25
+ const RECAP_PROMPT = 'Summarize this conversation so far in ten lines: goal, decisions, open items.';
15
26
  function detail(error) {
16
27
  return error instanceof Error ? error.message : String(error);
17
28
  }
@@ -50,6 +61,20 @@ function turnStartTime(events, turn) {
50
61
  return event.time;
51
62
  }
52
63
  }
64
+ function latestStep(events) {
65
+ for (let index = events.length - 1; index >= 0; index--) {
66
+ const event = events[index];
67
+ if (event?.type === 'step/start')
68
+ return event;
69
+ }
70
+ }
71
+ function openingStream(baseline) {
72
+ const attempt = baseline?.activeAttempt;
73
+ if (attempt === undefined)
74
+ return [];
75
+ return expandAssistantStream(attempt.stream).slice(0, attempt.nextIndex)
76
+ .reduce((cells, member) => foldAssistantStream(cells, attempt.turn, attempt.step, member.chunk), []);
77
+ }
53
78
  function titleFromEvent(event) {
54
79
  if (String(event.type) !== 'session/title')
55
80
  return undefined;
@@ -70,9 +95,33 @@ async function firstControl(iterator) {
70
95
  }
71
96
  return first.value;
72
97
  }
98
+ async function gitRepo(ctx, agent, signal) {
99
+ const fallback = basename(agent.session.header.cwd ?? '');
100
+ try {
101
+ const result = await ctx.shell.run(ctx.shell.resolve({
102
+ command: 'git rev-parse --show-toplevel && git branch --show-current', sandboxPolicy: ctx.sandboxPolicy.resolve({ mode: 'read-only' }),
103
+ signal, stdoutMaxBytes: 1_024, timeoutMs: 2_000, workdir: agent.session.header.cwd,
104
+ }));
105
+ const [root, branch] = result.stdout.text.trim().split(/\r?\n/u);
106
+ return result.exitCode === 0 && root !== undefined && branch !== undefined ? `${basename(root)}/${branch}` : fallback;
107
+ }
108
+ catch {
109
+ return fallback;
110
+ }
111
+ }
73
112
  function usage(raw, expected) {
74
113
  return raw.trim() === '' ? undefined : { kind: 'error', text: `usage: ${expected}` };
75
114
  }
115
+ function loopInterval(seconds) {
116
+ if (seconds % 3_600 === 0)
117
+ return `${String(seconds / 3_600)}h`;
118
+ return seconds % 60 === 0 ? `${String(seconds / 60)}m` : `${String(seconds)}s`;
119
+ }
120
+ function loopLine(schedule) {
121
+ const rule = schedule.kind === 'every' ? `every ${loopInterval(schedule.everySeconds)}`
122
+ : schedule.kind === 'after' ? `after ${String(schedule.afterSeconds)}s` : `at ${schedule.scheduledAt}`;
123
+ return `${String(schedule.id)} · ${rule} · ${schedule.prompt}`;
124
+ }
76
125
  function configValue(raw) {
77
126
  try {
78
127
  return JSON.parse(raw);
@@ -91,11 +140,13 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
91
140
  let started = false;
92
141
  let shutdown;
93
142
  let decisionSequence = 0;
94
- const initialId = options.resume === undefined
143
+ let initialId = options.resume === undefined
95
144
  ? options.continue
96
145
  ? continuation((await ctx.sessionController.list({}, lifetime.signal)).items, options.cwd)
97
- : (await ctx.sessionController.create({ cwd: options.cwd })).sessionId
146
+ : (await ctx.sessionController.create({ cwd: options.cwd, ...(options.agentPreset === undefined ? {} : { agentPreset: options.agentPreset }), ...(options.sessionId === undefined ? {} : { sessionId: SessionId(options.sessionId) }) })).sessionId
98
147
  : SessionId(options.resume);
148
+ if (options.forkSession)
149
+ initialId = (await ctx.sessionController.fork({ sessionId: initialId })).sessionId;
99
150
  const prepare = async (sessionId, name) => {
100
151
  const abort = new AbortController();
101
152
  try {
@@ -103,16 +154,37 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
103
154
  if ('error' in resolved)
104
155
  throw resolved.error;
105
156
  const agent = resolved.agent;
157
+ const disposers = [];
158
+ if (options.tools !== undefined || options.disallowedTools !== undefined) {
159
+ try {
160
+ disposers.push(agent.ctx.tools.restrict({
161
+ ...(options.tools === undefined ? {} : { allow: options.tools }),
162
+ ...(options.disallowedTools === undefined ? {} : { deny: options.disallowedTools }),
163
+ }));
164
+ }
165
+ catch (error) {
166
+ throw new LaunchArgumentError(detail(error));
167
+ }
168
+ }
169
+ if (options.systemPrompt !== undefined)
170
+ disposers.push(agent.ctx.systemPrompt.section({
171
+ complete: true, name: 'dashi:system-prompt', order: Number.MAX_SAFE_INTEGER, text: options.systemPrompt,
172
+ }));
173
+ if (options.appendSystemPrompt !== undefined)
174
+ disposers.push(agent.ctx.systemPrompt.section({
175
+ name: 'dashi:append-system-prompt', order: Number.MAX_SAFE_INTEGER, text: options.appendSystemPrompt,
176
+ }));
106
177
  let title;
107
178
  if (name !== undefined)
108
179
  title = (await ctx.sessionController.rename({ sessionId, title: name })).title;
109
180
  await ctx.sessions.flush(agent.session);
110
181
  const iterator = ctx.sessionController.follow({
111
- address: { kind: 'session', sessionId }, maxMessages: 50,
182
+ address: { kind: 'session', sessionId }, assistantStream: true, maxMessages: 50,
112
183
  }, abort.signal)[Symbol.asyncIterator]();
113
184
  const controlIterator = ctx.sessionController.control(abort.signal)[Symbol.asyncIterator]();
114
- const [opening, controlOpening, subagents] = await Promise.all([
185
+ const [opening, controlOpening, subagents, repo] = await Promise.all([
115
186
  firstSnapshot(iterator), firstControl(controlIterator), subagentViews(ctx, sessionId, abort.signal),
187
+ gitRepo(ctx, agent, abort.signal),
116
188
  ]);
117
189
  title ??= projectionString(opening.projections, 'title');
118
190
  const selected = projectionModel(opening.projections);
@@ -124,14 +196,16 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
124
196
  return {
125
197
  abort,
126
198
  agent,
199
+ repo,
127
200
  controlIterator,
128
201
  cursor: opening.cursor,
129
- disposers: [],
202
+ disposers,
130
203
  events: eventsFromRecords(opening.records),
131
204
  hasMore: opening.hasMore,
132
205
  iterator,
133
206
  presenter: createToolPresenter(tool => ctx.tools.get(tool, agent)),
134
207
  presentationTimer: undefined,
208
+ streamCells: openingStream(opening.assistantStream),
135
209
  root: {
136
210
  ...(initialContextPercent === undefined ? {} : { contextPercent: initialContextPercent }),
137
211
  cwd: opening.header.cwd ?? options.cwd,
@@ -164,7 +238,7 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
164
238
  };
165
239
  const executeAccepted = async (bound, line, images, onSettled) => {
166
240
  const from = bound.agent.session.seq;
167
- const execution = ctx.commands.execute(bound.agent, line, images, lifetime.signal);
241
+ const execution = ctx.commands.execute(bound.agent, line, images.map(image => ({ type: 'image', ...image })), lifetime.signal);
168
242
  const accepted = bound.agent.session.snapshotEvents(from).some(event => event.type === 'command/run');
169
243
  if (!accepted) {
170
244
  const result = await execution;
@@ -191,8 +265,18 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
191
265
  signal?.removeEventListener('abort', abortDecision);
192
266
  });
193
267
  };
268
+ const authorizationPrompt = (bound, prompt, detail) => decide({
269
+ answers: [], cursor: 0, custom: '', id: `authorization:${String(++decisionSequence)}`, index: 0, kind: 'question', owner: { id: bound.root.id, ...(bound.root.title === undefined ? {} : { label: bound.root.title }) }, selected: [],
270
+ questions: [{ allowCustom: prompt.kind !== 'select', ...(detail === undefined ? {} : { detail }), header: 'Login', id: 'authorization', multiSelect: false, options: prompt.kind === 'select' ? prompt.options : [], question: prompt.message, secret: prompt.kind === 'secret' }],
271
+ }, prompt.signal).then(result => prompt.kind === 'select' ? prompt.options[prompt.options.findIndex(option => option.label === result.answers[0]?.selected[0])]?.id ?? ''
272
+ : result.answers[0]?.custom ?? '').catch(error => {
273
+ if (prompt.signal?.aborted !== true && error.code === 'ASK_ABORTED')
274
+ throw new AuthorizationDeclinedError();
275
+ throw error;
276
+ });
194
277
  const transcriptCells = (bound) => [
195
278
  ...foldCells(bound.events, bound.presenter, { truncatedStart: bound.hasMore }),
279
+ ...bound.streamCells,
196
280
  ...pendingShellCells(bound.agent.inbox.nextStep),
197
281
  ];
198
282
  const publish = (bound) => {
@@ -224,12 +308,35 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
224
308
  });
225
309
  }, 34);
226
310
  };
311
+ const refreshRepo = async (bound) => {
312
+ const repo = await gitRepo(ctx, bound.agent, bound.abort.signal);
313
+ if (bound.abort.signal.aborted || current() !== bound || bound.repo === repo)
314
+ return;
315
+ bound.repo = repo;
316
+ dispatch({ type: 'redraw' });
317
+ };
227
318
  const consume = (bound, frame) => {
319
+ if (frame.type === 'assistant-stream') {
320
+ if (frame.frame.type !== 'chunk') {
321
+ bound.streamCells = [];
322
+ schedule(bound);
323
+ return;
324
+ }
325
+ const step = latestStep(bound.events);
326
+ if (step === undefined)
327
+ return;
328
+ bound.streamCells = foldAssistantStream(bound.streamCells, step.data.turn, step.data.step, frame.frame.chunk);
329
+ schedule(bound);
330
+ return;
331
+ }
228
332
  const [event] = eventsFromRecords([frame]);
229
333
  if (event === undefined)
230
334
  return;
231
335
  bound.events.push(event);
336
+ if (event.type === 'assistant/message' || event.type === 'assistant/attempt')
337
+ bound.streamCells = [];
232
338
  if (event.type === 'turn/end') {
339
+ void refreshRepo(bound);
233
340
  const startedAt = turnStartTime(bound.events, event.data.turn);
234
341
  if (startedAt !== undefined) {
235
342
  dispatch({
@@ -254,7 +361,7 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
254
361
  ...(model.reasoningEffort === undefined ? {} : { effort: model.reasoningEffort }),
255
362
  });
256
363
  }
257
- const prompt = humanPrompt(event);
364
+ const prompt = event.type === 'agent/inbox/spliced' ? undefined : historyInput(event);
258
365
  if (prompt !== undefined)
259
366
  dispatch({ type: 'prompt-recorded', rootId: bound.root.id, text: prompt });
260
367
  schedule(bound);
@@ -333,7 +440,7 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
333
440
  tuiRoot.bind(candidate.agent);
334
441
  dispatch({
335
442
  type: 'root-bound', cells: transcriptCells(candidate),
336
- hasMore: candidate.hasMore, prompts: humanPrompts(candidate.events), root: candidate.root,
443
+ hasMore: candidate.hasMore, prompts: historyInputs(candidate.events), root: candidate.root,
337
444
  });
338
445
  if (started) {
339
446
  candidate.pump = follow(candidate);
@@ -374,13 +481,12 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
374
481
  ?? ctx.agentPresets.defaultId;
375
482
  const modelProjection = ctx.sessionProjections.snapshot(bound.agent.session, ['modelSelection'])
376
483
  .values.modelSelection;
377
- const selection = modelProjection?.next ?? modelProjection?.lastUsed ?? undefined;
484
+ const selection = rewindModelSelection(modelProjection);
378
485
  const created = await ctx.sessionController.create({ cwd: bound.root.cwd, agentPreset });
379
486
  let selectionFailure;
380
487
  try {
381
- if (selection === undefined)
382
- throw new Error('the current session has no durable model selection');
383
- await ctx.sessionController.selectModel({ sessionId: created.sessionId, ...selection });
488
+ if (selection !== undefined)
489
+ await ctx.sessionController.selectModel({ sessionId: created.sessionId, ...selection });
384
490
  }
385
491
  catch (error) {
386
492
  selectionFailure = error;
@@ -432,7 +538,7 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
432
538
  const roller = ctx.commands.list(bound.agent).some(command => command.name === 'roller-restore');
433
539
  const overlay = rewindOverlay(bound.agent.session.snapshotEvents(), roller);
434
540
  if (overlay.kind !== 'list' || overlay.options.length === 0) {
435
- throw new Error('the current session has no human prompt to rewind');
541
+ throw new Error('the current session has no input to rewind');
436
542
  }
437
543
  dispatch({ type: 'open-overlay', overlay });
438
544
  return;
@@ -464,7 +570,100 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
464
570
  if (nativePermission === undefined)
465
571
  throw new Error('dashi: DSH supplied no /permission command');
466
572
  const ensureCurrent = (invocation) => current().agent === invocation.agent ? undefined : { kind: 'error', text: 'this root is no longer current' };
467
- return [
573
+ const tasks = (invocation) => {
574
+ const stale = ensureCurrent(invocation);
575
+ if (stale !== undefined)
576
+ return stale;
577
+ const raw = invocation.rawInput.trim();
578
+ if (raw === '') {
579
+ dispatch({ type: 'open-details' });
580
+ return { kind: 'success' };
581
+ }
582
+ const match = /^kill\s+(\S+)$/u.exec(raw);
583
+ if (match?.[1] === undefined)
584
+ return { kind: 'error', text: 'usage: /tasks [kill ID]' };
585
+ const result = ctx.jobs.kill(JobId(match[1]), bound.agent, 'stopped by /tasks');
586
+ return { kind: 'success', text: result === 'requested' ? `stopping ${match[1]}` : `${match[1]} already finished` };
587
+ };
588
+ const loop = async (invocation) => {
589
+ const stale = ensureCurrent(invocation);
590
+ if (stale !== undefined)
591
+ return stale;
592
+ const raw = invocation.rawInput.trim();
593
+ const stop = /^stop\s+(\S+)$/u.exec(raw);
594
+ const create = /^(\d+)([mh])\s+(.+)$/su.exec(raw);
595
+ if (raw !== '' && stop === null && create === null) {
596
+ return { kind: 'error', text: 'usage: /loop [INTERVAL TEXT | stop ID]' };
597
+ }
598
+ const name = raw === '' ? 'schedule_list' : stop === null ? 'schedule_create' : 'schedule_delete';
599
+ const args = raw === '' ? {} : stop === null ? {
600
+ every_seconds: Number(create[1]) * (create[2] === 'h' ? 3_600 : 60), prompt: create[3],
601
+ } : { id: stop[1] };
602
+ const result = await ctx.agents.withInitiator(bound.agent, () => ctx.tools.execute({
603
+ agent: bound.agent, arguments: args, callId: ToolCallId(`${String(invocation.commandId)}:loop`),
604
+ name, signal: invocation.signal,
605
+ }));
606
+ if (result.isError)
607
+ return { kind: 'error', text: result.error.message };
608
+ const value = result.value;
609
+ if (!Array.isArray(value) && 'message' in value)
610
+ return { kind: 'error', text: value.message };
611
+ if (Array.isArray(value))
612
+ return { kind: 'success', text: value.length === 0 ? 'no active schedules' : value.map(loopLine).join('\n') };
613
+ if ('deleted' in value)
614
+ return value.deleted ? { kind: 'success', text: `stopped ${String(value.id)}` }
615
+ : { kind: 'error', text: `schedule ${String(value.id)} not found` };
616
+ return { kind: 'success', text: `scheduled ${loopLine(value)}` };
617
+ };
618
+ const askAside = async (invocation, prompt = invocation.rawInput.trim()) => {
619
+ const stale = ensureCurrent(invocation);
620
+ if (stale !== undefined)
621
+ return stale;
622
+ if (prompt === '')
623
+ return { kind: 'error', text: 'usage: /btw TEXT' };
624
+ if (bound.agent.status !== 'idle')
625
+ return { kind: 'error', text: 'finish or interrupt the turn first' };
626
+ const boundary = latestCompletedTurn(bound.agent.session.snapshotEvents());
627
+ if (boundary.atSeq === undefined)
628
+ throw new Error('the current session has no completed turn to fork');
629
+ const forked = await ctx.sessionController.fork({ atSeq: boundary.atSeq, sessionId: bound.agent.id });
630
+ const resolved = await ctx.sessionController.resolveAgent(forked.sessionId);
631
+ if ('error' in resolved)
632
+ throw resolved.error;
633
+ const child = resolved.agent;
634
+ const turn = inheritedTurn(child.session.snapshotEvents(), child.session.inheritedEventCount);
635
+ if (turn === undefined)
636
+ throw new Error('the fork contains no completed turn');
637
+ const iterator = ctx.sessionController.follow({
638
+ address: { kind: 'session', sessionId: child.id }, maxMessages: 50,
639
+ }, invocation.signal)[Symbol.asyncIterator]();
640
+ await firstSnapshot(iterator);
641
+ await ctx.sessionController.rename({
642
+ sessionId: child.id, title: `btw · ${prompt.split(/\s+/u).slice(0, 6).join(' ')}`,
643
+ });
644
+ const from = child.session.seq;
645
+ child.steer(createUserMessage({ content: [{ type: 'text', text: prompt }], source: { kind: 'user' } }));
646
+ try {
647
+ for (;;) {
648
+ const next = await iterator.next();
649
+ if (next.done === true)
650
+ throw new Error('DSH session follow ended before the side turn completed');
651
+ if (next.value.type === 'event' && eventsFromRecords([next.value])[0]?.type === 'turn/end')
652
+ break;
653
+ }
654
+ }
655
+ finally {
656
+ await iterator.return?.();
657
+ }
658
+ await ctx.sessions.flush(child.session);
659
+ const cells = foldCells(child.session.snapshotEvents(from), createToolPresenter(tool => ctx.tools.get(tool, child)))
660
+ .filter(cell => cell.kind !== 'user');
661
+ dispatch({ type: 'open-overlay', overlay: {
662
+ cells, kind: 'info', lines: [], title: `Btw · turn ${String(turn)}${boundary.open ? ' · trailing turn open' : ''}`,
663
+ } });
664
+ return { kind: 'success' };
665
+ };
666
+ const definitions = [
468
667
  {
469
668
  name: 'help', description: 'Show keyboard and command help',
470
669
  handler: (invocation) => {
@@ -504,6 +703,16 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
504
703
  return { kind: 'success' };
505
704
  },
506
705
  },
706
+ {
707
+ name: 'usage', description: 'Show session token and timing totals',
708
+ handler: (invocation) => {
709
+ const invalid = ensureCurrent(invocation) ?? usage(invocation.rawInput, '/usage');
710
+ if (invalid !== undefined)
711
+ return invalid;
712
+ dispatch({ type: 'open-overlay', overlay: usageOverlay(ctx, bound.agent) });
713
+ return { kind: 'success' };
714
+ },
715
+ },
507
716
  {
508
717
  name: 'memory', description: 'Open an instruction file loaded by DSH',
509
718
  handler: (invocation) => {
@@ -514,6 +723,20 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
514
723
  return { kind: 'success' };
515
724
  },
516
725
  },
726
+ {
727
+ name: 'skills', description: 'List skills resolved for this session', input: { hint: '[TEXT]' },
728
+ handler: async (invocation) => {
729
+ const stale = ensureCurrent(invocation);
730
+ if (stale !== undefined)
731
+ return stale;
732
+ const overlay = await skillOverlay(ctx, bound.agent, invocation.rawInput, invocation.signal);
733
+ const replaced = ensureCurrent(invocation);
734
+ if (replaced !== undefined)
735
+ return replaced;
736
+ dispatch({ type: 'open-overlay', overlay });
737
+ return { kind: 'success' };
738
+ },
739
+ },
517
740
  {
518
741
  name: 'config', description: 'Show or update native DSH settings', input: { hint: '[NAMESPACE KEY=VALUE]' },
519
742
  handler: async (invocation) => {
@@ -535,6 +758,43 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
535
758
  return { kind: 'success', text: `updated ${match[1]}.${match[2]}` };
536
759
  },
537
760
  },
761
+ { name: 'login', description: 'List or start provider sign-in', input: { hint: '[KEY [METHOD]]' },
762
+ handler: async (invocation) => {
763
+ const stale = ensureCurrent(invocation);
764
+ if (stale !== undefined)
765
+ return stale;
766
+ const values = invocation.rawInput.trim().split(/\s+/u).filter(Boolean);
767
+ if (values.length === 0)
768
+ return { kind: 'success', text: ctx.authorization.list().map(entry => `${String(entry.key)} · ${entry.label} · ${entry.methods.map(method => `${method.id}: ${method.label}`).join(', ')}${entry.inFlight ? ' · in progress' : ''}`).join('\n') || 'no authorization flows' };
769
+ if (values.length > 2)
770
+ return { kind: 'error', text: 'usage: /login [KEY [METHOD]]' };
771
+ const entry = ctx.authorization.describe(parseCredentialKey(values[0]));
772
+ if (entry === undefined)
773
+ return { kind: 'error', text: `no authorization flow for ${values[0]}` };
774
+ const notices = [];
775
+ const outcome = await ctx.authorization.begin({ key: entry.key, ...(values[1] === undefined ? {} : { method: values[1] }), signal: invocation.signal,
776
+ interaction: { notify: notice => {
777
+ notices.push(notice.message, ...(notice.url === undefined ? [] : [notice.url]), ...(notice.code === undefined ? [] : [`Code: ${notice.code}`]));
778
+ dispatch({ type: 'open-overlay', overlay: { kind: 'info', title: 'Login', lines: notices } });
779
+ }, prompt: prompt => authorizationPrompt(bound, prompt, notices.join('\n')) },
780
+ }).finally(() => { dispatch({ type: 'overlay-close' }); });
781
+ return { kind: 'success', text: outcome.status === 'authorized' ? `signed in to ${entry.label}` : 'login cancelled' };
782
+ },
783
+ },
784
+ { name: 'logout', description: 'List or forget provider credentials', input: { hint: '[KEY]' },
785
+ handler: async (invocation) => {
786
+ const stale = ensureCurrent(invocation);
787
+ if (stale !== undefined)
788
+ return stale;
789
+ const raw = invocation.rawInput.trim();
790
+ if (raw === '')
791
+ return { kind: 'success', text: (await ctx.credentials.listRecords()).map(record => `${String(record.key)} · ${record.kind}`).join('\n') || 'no stored credentials' };
792
+ if (/\s/u.test(raw))
793
+ return { kind: 'error', text: 'usage: /logout [KEY]' };
794
+ await ctx.credentials.deleteRecord(parseCredentialKey(raw));
795
+ return { kind: 'success', text: `signed out ${raw}` };
796
+ },
797
+ },
538
798
  {
539
799
  name: 'diff', description: 'Show working tree or last-turn file changes', input: { hint: '[turn]' }, handler: async (invocation) => {
540
800
  const invalid = ensureCurrent(invocation);
@@ -573,7 +833,7 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
573
833
  if (invocation.rawInput.trim() === '')
574
834
  return { kind: 'error', text: 'usage: /plugin ARGS' };
575
835
  const command = `dsh plugin --profile ${quoteShellWord(decodeURIComponent(new URL(ctx.baseUrl).pathname).split('/').at(-2))}${invocation.rawInput}`;
576
- const [message, result] = await bound.agent.runMaintenance(signal => runHumanShell(ctx, bound.agent, command, signal, 'changes load on the next launch; exit and run dashi again'));
836
+ const [message, result] = await bound.agent.runMaintenance(signal => runHumanShell(ctx, bound.agent, command, signal, 'bundles load on the next launch; plain plugins need a patch row', false));
577
837
  bound.agent.inject(message);
578
838
  await ctx.sessions.flush(bound.agent.session);
579
839
  publish(bound);
@@ -581,29 +841,35 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
581
841
  },
582
842
  },
583
843
  {
584
- name: 'new', description: 'Start a new session', input: { hint: '[--name TITLE]' },
844
+ name: 'new', description: 'Start a new session', input: { hint: '[NAME | --name TITLE]' },
585
845
  handler: async (invocation) => {
586
846
  const stale = ensureCurrent(invocation);
587
847
  if (stale !== undefined)
588
848
  return stale;
589
849
  const raw = invocation.rawInput.trim();
590
850
  const match = /^--name\s+(.+)$/su.exec(raw);
591
- if (raw !== '' && match === null)
592
- return { kind: 'error', text: 'usage: /new [--name TITLE]' };
851
+ if (raw.startsWith('--') && match === null)
852
+ return { kind: 'error', text: 'usage: /new [NAME | --name TITLE]' };
593
853
  const title = match?.[1]?.trim();
854
+ if (raw !== '' && match === null) {
855
+ await ctx.sessionController.rename({ sessionId: bound.agent.id, title: raw });
856
+ await ctx.sessions.flush(bound.agent.session);
857
+ }
594
858
  await runRootOperation({ kind: 'new', ...(title === undefined ? {} : { title }) });
595
859
  return { kind: 'success' };
596
860
  },
597
861
  },
598
862
  {
599
- name: 'clear', description: 'Start a new session',
863
+ name: 'clear', description: 'Start a new session', input: { hint: '[NAME]' },
600
864
  handler: async (invocation) => {
601
865
  const stale = ensureCurrent(invocation);
602
866
  if (stale !== undefined)
603
867
  return stale;
604
- const invalid = usage(invocation.rawInput, '/clear');
605
- if (invalid !== undefined)
606
- return invalid;
868
+ const title = invocation.rawInput.trim();
869
+ if (title !== '') {
870
+ await ctx.sessionController.rename({ sessionId: bound.agent.id, title });
871
+ await ctx.sessions.flush(bound.agent.session);
872
+ }
607
873
  await runRootOperation({ kind: 'new' });
608
874
  return { kind: 'success' };
609
875
  },
@@ -664,30 +930,71 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
664
930
  },
665
931
  },
666
932
  {
667
- name: 'tasks', description: 'Show jobs and subagents',
668
- handler: (invocation) => {
933
+ name: 'tasks', description: 'Show jobs and subagents, or kill a job', input: { hint: '[kill ID]' },
934
+ handler: tasks,
935
+ },
936
+ {
937
+ name: 'bashes', description: 'Alias of /tasks', input: { hint: '[kill ID]' },
938
+ handler: tasks,
939
+ },
940
+ {
941
+ name: 'subtask', description: 'Start a continuable child', input: { hint: 'TEXT' },
942
+ handler: async (invocation) => {
669
943
  const stale = ensureCurrent(invocation);
670
944
  if (stale !== undefined)
671
945
  return stale;
672
- const invalid = usage(invocation.rawInput, '/tasks');
673
- if (invalid !== undefined)
674
- return invalid;
675
- dispatch({ type: 'open-details' });
676
- return { kind: 'success' };
946
+ const text = invocation.rawInput.trim();
947
+ if (text === '')
948
+ return { kind: 'error', text: 'usage: /subtask TEXT' };
949
+ const started = await ctx.subagents.startContinuable({
950
+ label: text, provider: 'spawn',
951
+ request: { parent: bound.agent, prompt: [{ type: 'text', text }] },
952
+ signal: invocation.signal,
953
+ });
954
+ return { kind: 'success', text: `subtask ${String(started.childId)} started` };
677
955
  },
678
956
  },
679
957
  {
680
- name: 'agents', description: 'Choose the native DSH agent preset',
958
+ name: 'loop', description: 'List, schedule, or stop a fixed-rate reminder',
959
+ input: { hint: '[INTERVAL TEXT | stop ID]' }, handler: loop,
960
+ },
961
+ {
962
+ name: 'btw', description: 'Ask a side question in a fork', input: { hint: 'TEXT' }, recordInput: false,
963
+ handler: invocation => askAside(invocation),
964
+ },
965
+ {
966
+ name: 'recap', description: 'Summarize the conversation in a fork', recordInput: false,
967
+ handler: invocation => usage(invocation.rawInput, '/recap') ?? askAside(invocation, RECAP_PROMPT),
968
+ },
969
+ {
970
+ name: 'agents', description: 'Choose or author a native DSH agent preset',
971
+ input: { hint: '[new NAME | copy SRC DEST | delete NAME]' },
681
972
  handler: async (invocation) => {
682
973
  const stale = ensureCurrent(invocation);
683
974
  if (stale !== undefined)
684
975
  return stale;
685
- const invalid = usage(invocation.rawInput, '/agents');
686
- if (invalid !== undefined)
687
- return invalid;
976
+ const raw = invocation.rawInput.trim();
688
977
  const blank = !bound.agent.session.snapshotEvents().some(event => event.type === 'turn/start');
689
978
  const selected = ctx.sessionProjections.stateOf(bound.agent.session, 'agentPreset')
690
979
  ?? ctx.agentPresets.defaultId;
980
+ const create = /^new\s+(\S+)$/u.exec(raw);
981
+ const copy = /^copy\s+(\S+)\s+(\S+)$/u.exec(raw);
982
+ const remove = /^delete\s+(\S+)$/u.exec(raw);
983
+ if (create?.[1] !== undefined) {
984
+ await ctx.agentPresets.copy(selected, create[1]);
985
+ dispatch({ type: 'open-file', path: (await ctx.agentPresets.resolve(create[1])).path });
986
+ return { kind: 'success', text: `created ${create[1]} from ${selected}` };
987
+ }
988
+ if (copy?.[1] !== undefined && copy[2] !== undefined) {
989
+ await ctx.agentPresets.copy(copy[1], copy[2]);
990
+ return { kind: 'success', text: `copied ${copy[1]} to ${copy[2]}` };
991
+ }
992
+ if (remove?.[1] !== undefined) {
993
+ await ctx.agentPresets.remove(remove[1]);
994
+ return { kind: 'success', text: `deleted ${remove[1]}` };
995
+ }
996
+ if (raw !== '')
997
+ return { kind: 'error', text: 'usage: /agents [new NAME | copy SRC DEST | delete NAME]' };
691
998
  dispatch({
692
999
  type: 'open-overlay',
693
1000
  overlay: agentPresetOverlay(await ctx.agentPresets.list(), selected, blank),
@@ -717,15 +1024,17 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
717
1024
  },
718
1025
  },
719
1026
  {
720
- name: 'copy', description: 'Copy the latest completed assistant response',
1027
+ name: 'copy', description: 'Copy an assistant response or code block', input: { hint: '[N|code]' },
721
1028
  handler: (invocation) => {
722
1029
  const stale = ensureCurrent(invocation);
723
1030
  if (stale !== undefined)
724
1031
  return stale;
725
- const invalid = usage(invocation.rawInput, '/copy');
726
- if (invalid !== undefined)
727
- return invalid;
728
- dispatch({ type: 'copy-latest' });
1032
+ const raw = invocation.rawInput.trim();
1033
+ const selection = raw === '' ? 1 : raw === 'code' ? 'code' : Number(raw);
1034
+ if (raw !== '' && selection !== 'code' && (!/^[1-9]\d*$/u.test(raw) || !Number.isSafeInteger(selection))) {
1035
+ return { kind: 'error', text: 'usage: /copy [N|code]' };
1036
+ }
1037
+ dispatch({ selection, type: 'copy-assistant' });
729
1038
  return { kind: 'success' };
730
1039
  },
731
1040
  },
@@ -758,6 +1067,32 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
758
1067
  return { kind: 'success' };
759
1068
  },
760
1069
  },
1070
+ {
1071
+ name: 'init', description: 'Create a starter AGENTS.md',
1072
+ handler: async (invocation) => {
1073
+ const stale = ensureCurrent(invocation);
1074
+ if (stale !== undefined)
1075
+ return stale;
1076
+ const invalid = usage(invocation.rawInput, '/init');
1077
+ if (invalid !== undefined)
1078
+ return invalid;
1079
+ const target = await ctx.fs.resolve('AGENTS.md', { cwd: bound.root.cwd, signal: lifetime.signal });
1080
+ await ctx.fs.writeText(target, starterAgents(bound.root.cwd), { kind: 'createIfAbsent' }, lifetime.signal, ctx.sandboxPolicy.resolve({ session: bound.agent.session }));
1081
+ return { kind: 'success', text: `created ${ctx.fs.processPath(target)}` };
1082
+ },
1083
+ },
1084
+ {
1085
+ name: 'effort', description: 'Select the reasoning effort', input: { hint: 'LEVEL' },
1086
+ handler: async (invocation) => {
1087
+ const invalid = ensureCurrent(invocation);
1088
+ const effort = invocation.rawInput.trim();
1089
+ if (invalid !== undefined || effort === '')
1090
+ return invalid ?? { kind: 'error', text: 'usage: /effort LEVEL' };
1091
+ const selected = currentModel(ctx, bound.agent) ?? (await ctx.sessionController.modelCatalog()).default;
1092
+ await activate({ kind: 'model', effort, model: selected.model, provider: selected.provider });
1093
+ return { kind: 'success' };
1094
+ },
1095
+ },
761
1096
  {
762
1097
  ...nativePermission,
763
1098
  handler: async (invocation) => {
@@ -798,6 +1133,8 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
798
1133
  },
799
1134
  },
800
1135
  ];
1136
+ return [...definitions, ...[['quit', 'exit'], ['reset', 'clear'], ['continue', 'resume'], ['branch', 'fork']]
1137
+ .map(([name, target]) => ({ ...definitions.find(definition => definition.name === target), name }))];
801
1138
  };
802
1139
  async function install(bound) {
803
1140
  const id = bound.root.id;
@@ -933,6 +1270,14 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
933
1270
  }
934
1271
  break;
935
1272
  }
1273
+ case 'job-output': {
1274
+ const result = ctx.jobs.read(JobId(value.jobId), bound.agent);
1275
+ dispatch({ type: 'open-overlay', overlay: {
1276
+ cells: [{ key: `job:${value.jobId}`, kind: 'outcome', text: result.text }],
1277
+ kind: 'info', lines: [], title: `Job ${value.jobId} · ${result.snapshot.status}`,
1278
+ } });
1279
+ break;
1280
+ }
936
1281
  case 'model': {
937
1282
  const result = await ctx.sessionController.selectModel({
938
1283
  sessionId: bound.agent.id, model: value.model, provider: value.provider,
@@ -970,8 +1315,9 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
970
1315
  initialAttachments,
971
1316
  get initialCells() { return transcriptCells(current()); },
972
1317
  get initialHasMore() { return current().hasMore; },
973
- get initialPrompts() { return humanPrompts(current().events); },
1318
+ get initialPrompts() { return historyInputs(current().events); },
974
1319
  get root() { return current().root; },
1320
+ get statusLine() { const bound = current(); return sessionStatusLine(ctx, bound.agent, bound.root, bound.repo); },
975
1321
  get summary() { return `Resume with: dsh --profile dashi --resume ${current().root.id}`; },
976
1322
  activate,
977
1323
  async attach(path) {
@@ -1120,6 +1466,7 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
1120
1466
  bound.agent.inject(message);
1121
1467
  await ctx.sessions.flush(bound.agent.session);
1122
1468
  });
1469
+ dispatch({ type: 'prompt-recorded', rootId: bound.root.id, text: `!${command}` });
1123
1470
  publish(bound);
1124
1471
  return;
1125
1472
  }