@antst/dashi 0.1.0-alpha.9 → 0.1.1

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,23 +1,28 @@
1
1
  import { basename } from 'node:path';
2
2
  import { AuthorizationDeclinedError } from '@deepseek-ai/dsh-authorization';
3
3
  import { parseCredentialKey } from '@deepseek-ai/dsh-credentials';
4
- import { createUserMessage } from '@deepseek-ai/dsh-llm';
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';
5
7
  import { SessionId, SessionSeq } from '@deepseek-ai/dsh-session';
6
- import { agentPresetOverlay, completionOptions, contextOverlay, currentModel, modelOverlay, permissionOverlay, searchOverlay, sessionOverlay, skillOverlay, statusOverlay, } from './catalogs.js';
8
+ import { agentPresetOverlay, completionOptions, contextOverlay, currentModel, modelOverlay, permissionOverlay, searchOverlay, sessionStatusLine, sessionOverlay, skillOverlay, statusOverlay, usageOverlay, } from './catalogs.js';
7
9
  import { diffOverlay } from './diff-view.js';
8
10
  import { eventsFromRecords } from './history-records.js';
9
11
  import { admitDraftImages, encodeDraftImages, readDraftImages } from './image-input.js';
10
12
  import { contextPercent, jobViews, subagentViews } from './presentation.js';
11
- import { humanPrompt, humanPrompts, inheritedTurn, rewindActionOverlay, rewindOverlay, } from './rewind.js';
13
+ import { historyInput, historyInputs, humanPrompt, inheritedTurn, latestCompletedTurn, rewindActionOverlay, rewindOverlay, rewindModelSelection, } from './rewind.js';
12
14
  import { createToolPresenter } from './tool-presentation.js';
13
15
  import { quoteShellWord, runHumanShell } from './shell-command.js';
14
16
  import { markdownTranscript } from './session-export.js';
15
17
  import { isSessionId, sessionMatches, sessionNotFound } from './session-list.js';
16
18
  import { memoryOverlay } from './memory.js';
17
- import { foldCells, pendingShellCells } from './transcript.js';
19
+ import { foldAssistantStream, foldCells, pendingShellCells } from './transcript.js';
20
+ export class LaunchArgumentError extends Error {
21
+ }
18
22
  function starterAgents(cwd) {
19
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`;
20
24
  }
25
+ const RECAP_PROMPT = 'Summarize this conversation so far in ten lines: goal, decisions, open items.';
21
26
  function detail(error) {
22
27
  return error instanceof Error ? error.message : String(error);
23
28
  }
@@ -56,6 +61,20 @@ function turnStartTime(events, turn) {
56
61
  return event.time;
57
62
  }
58
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
+ }
59
78
  function titleFromEvent(event) {
60
79
  if (String(event.type) !== 'session/title')
61
80
  return undefined;
@@ -76,9 +95,33 @@ async function firstControl(iterator) {
76
95
  }
77
96
  return first.value;
78
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
+ }
79
112
  function usage(raw, expected) {
80
113
  return raw.trim() === '' ? undefined : { kind: 'error', text: `usage: ${expected}` };
81
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
+ }
82
125
  function configValue(raw) {
83
126
  try {
84
127
  return JSON.parse(raw);
@@ -111,16 +154,37 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
111
154
  if ('error' in resolved)
112
155
  throw resolved.error;
113
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
+ }));
114
177
  let title;
115
178
  if (name !== undefined)
116
179
  title = (await ctx.sessionController.rename({ sessionId, title: name })).title;
117
180
  await ctx.sessions.flush(agent.session);
118
181
  const iterator = ctx.sessionController.follow({
119
- address: { kind: 'session', sessionId }, maxMessages: 50,
182
+ address: { kind: 'session', sessionId }, assistantStream: true, maxMessages: 50,
120
183
  }, abort.signal)[Symbol.asyncIterator]();
121
184
  const controlIterator = ctx.sessionController.control(abort.signal)[Symbol.asyncIterator]();
122
- const [opening, controlOpening, subagents] = await Promise.all([
185
+ const [opening, controlOpening, subagents, repo] = await Promise.all([
123
186
  firstSnapshot(iterator), firstControl(controlIterator), subagentViews(ctx, sessionId, abort.signal),
187
+ gitRepo(ctx, agent, abort.signal),
124
188
  ]);
125
189
  title ??= projectionString(opening.projections, 'title');
126
190
  const selected = projectionModel(opening.projections);
@@ -132,14 +196,16 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
132
196
  return {
133
197
  abort,
134
198
  agent,
199
+ repo,
135
200
  controlIterator,
136
201
  cursor: opening.cursor,
137
- disposers: [],
202
+ disposers,
138
203
  events: eventsFromRecords(opening.records),
139
204
  hasMore: opening.hasMore,
140
205
  iterator,
141
206
  presenter: createToolPresenter(tool => ctx.tools.get(tool, agent)),
142
207
  presentationTimer: undefined,
208
+ streamCells: openingStream(opening.assistantStream),
143
209
  root: {
144
210
  ...(initialContextPercent === undefined ? {} : { contextPercent: initialContextPercent }),
145
211
  cwd: opening.header.cwd ?? options.cwd,
@@ -172,7 +238,7 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
172
238
  };
173
239
  const executeAccepted = async (bound, line, images, onSettled) => {
174
240
  const from = bound.agent.session.seq;
175
- 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);
176
242
  const accepted = bound.agent.session.snapshotEvents(from).some(event => event.type === 'command/run');
177
243
  if (!accepted) {
178
244
  const result = await execution;
@@ -210,6 +276,7 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
210
276
  });
211
277
  const transcriptCells = (bound) => [
212
278
  ...foldCells(bound.events, bound.presenter, { truncatedStart: bound.hasMore }),
279
+ ...bound.streamCells,
213
280
  ...pendingShellCells(bound.agent.inbox.nextStep),
214
281
  ];
215
282
  const publish = (bound) => {
@@ -241,12 +308,35 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
241
308
  });
242
309
  }, 34);
243
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
+ };
244
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
+ }
245
332
  const [event] = eventsFromRecords([frame]);
246
333
  if (event === undefined)
247
334
  return;
248
335
  bound.events.push(event);
336
+ if (event.type === 'assistant/message' || event.type === 'assistant/attempt')
337
+ bound.streamCells = [];
249
338
  if (event.type === 'turn/end') {
339
+ void refreshRepo(bound);
250
340
  const startedAt = turnStartTime(bound.events, event.data.turn);
251
341
  if (startedAt !== undefined) {
252
342
  dispatch({
@@ -271,7 +361,7 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
271
361
  ...(model.reasoningEffort === undefined ? {} : { effort: model.reasoningEffort }),
272
362
  });
273
363
  }
274
- const prompt = humanPrompt(event);
364
+ const prompt = event.type === 'agent/inbox/spliced' ? undefined : historyInput(event);
275
365
  if (prompt !== undefined)
276
366
  dispatch({ type: 'prompt-recorded', rootId: bound.root.id, text: prompt });
277
367
  schedule(bound);
@@ -350,7 +440,7 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
350
440
  tuiRoot.bind(candidate.agent);
351
441
  dispatch({
352
442
  type: 'root-bound', cells: transcriptCells(candidate),
353
- hasMore: candidate.hasMore, prompts: humanPrompts(candidate.events), root: candidate.root,
443
+ hasMore: candidate.hasMore, prompts: historyInputs(candidate.events), root: candidate.root,
354
444
  });
355
445
  if (started) {
356
446
  candidate.pump = follow(candidate);
@@ -391,13 +481,12 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
391
481
  ?? ctx.agentPresets.defaultId;
392
482
  const modelProjection = ctx.sessionProjections.snapshot(bound.agent.session, ['modelSelection'])
393
483
  .values.modelSelection;
394
- const selection = modelProjection?.next ?? modelProjection?.lastUsed ?? undefined;
484
+ const selection = rewindModelSelection(modelProjection);
395
485
  const created = await ctx.sessionController.create({ cwd: bound.root.cwd, agentPreset });
396
486
  let selectionFailure;
397
487
  try {
398
- if (selection === undefined)
399
- throw new Error('the current session has no durable model selection');
400
- await ctx.sessionController.selectModel({ sessionId: created.sessionId, ...selection });
488
+ if (selection !== undefined)
489
+ await ctx.sessionController.selectModel({ sessionId: created.sessionId, ...selection });
401
490
  }
402
491
  catch (error) {
403
492
  selectionFailure = error;
@@ -449,7 +538,7 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
449
538
  const roller = ctx.commands.list(bound.agent).some(command => command.name === 'roller-restore');
450
539
  const overlay = rewindOverlay(bound.agent.session.snapshotEvents(), roller);
451
540
  if (overlay.kind !== 'list' || overlay.options.length === 0) {
452
- throw new Error('the current session has no human prompt to rewind');
541
+ throw new Error('the current session has no input to rewind');
453
542
  }
454
543
  dispatch({ type: 'open-overlay', overlay });
455
544
  return;
@@ -481,6 +570,99 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
481
570
  if (nativePermission === undefined)
482
571
  throw new Error('dashi: DSH supplied no /permission command');
483
572
  const ensureCurrent = (invocation) => current().agent === invocation.agent ? undefined : { kind: 'error', text: 'this root is no longer current' };
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
+ };
484
666
  const definitions = [
485
667
  {
486
668
  name: 'help', description: 'Show keyboard and command help',
@@ -521,6 +703,16 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
521
703
  return { kind: 'success' };
522
704
  },
523
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
+ },
524
716
  {
525
717
  name: 'memory', description: 'Open an instruction file loaded by DSH',
526
718
  handler: (invocation) => {
@@ -641,7 +833,7 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
641
833
  if (invocation.rawInput.trim() === '')
642
834
  return { kind: 'error', text: 'usage: /plugin ARGS' };
643
835
  const command = `dsh plugin --profile ${quoteShellWord(decodeURIComponent(new URL(ctx.baseUrl).pathname).split('/').at(-2))}${invocation.rawInput}`;
644
- 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));
645
837
  bound.agent.inject(message);
646
838
  await ctx.sessions.flush(bound.agent.session);
647
839
  publish(bound);
@@ -649,29 +841,35 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
649
841
  },
650
842
  },
651
843
  {
652
- name: 'new', description: 'Start a new session', input: { hint: '[--name TITLE]' },
844
+ name: 'new', description: 'Start a new session', input: { hint: '[NAME | --name TITLE]' },
653
845
  handler: async (invocation) => {
654
846
  const stale = ensureCurrent(invocation);
655
847
  if (stale !== undefined)
656
848
  return stale;
657
849
  const raw = invocation.rawInput.trim();
658
850
  const match = /^--name\s+(.+)$/su.exec(raw);
659
- if (raw !== '' && match === null)
660
- return { kind: 'error', text: 'usage: /new [--name TITLE]' };
851
+ if (raw.startsWith('--') && match === null)
852
+ return { kind: 'error', text: 'usage: /new [NAME | --name TITLE]' };
661
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
+ }
662
858
  await runRootOperation({ kind: 'new', ...(title === undefined ? {} : { title }) });
663
859
  return { kind: 'success' };
664
860
  },
665
861
  },
666
862
  {
667
- name: 'clear', description: 'Start a new session',
863
+ name: 'clear', description: 'Start a new session', input: { hint: '[NAME]' },
668
864
  handler: async (invocation) => {
669
865
  const stale = ensureCurrent(invocation);
670
866
  if (stale !== undefined)
671
867
  return stale;
672
- const invalid = usage(invocation.rawInput, '/clear');
673
- if (invalid !== undefined)
674
- 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
+ }
675
873
  await runRootOperation({ kind: 'new' });
676
874
  return { kind: 'success' };
677
875
  },
@@ -732,30 +930,71 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
732
930
  },
733
931
  },
734
932
  {
735
- name: 'tasks', description: 'Show jobs and subagents',
736
- 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) => {
737
943
  const stale = ensureCurrent(invocation);
738
944
  if (stale !== undefined)
739
945
  return stale;
740
- const invalid = usage(invocation.rawInput, '/tasks');
741
- if (invalid !== undefined)
742
- return invalid;
743
- dispatch({ type: 'open-details' });
744
- 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` };
745
955
  },
746
956
  },
747
957
  {
748
- 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]' },
749
972
  handler: async (invocation) => {
750
973
  const stale = ensureCurrent(invocation);
751
974
  if (stale !== undefined)
752
975
  return stale;
753
- const invalid = usage(invocation.rawInput, '/agents');
754
- if (invalid !== undefined)
755
- return invalid;
976
+ const raw = invocation.rawInput.trim();
756
977
  const blank = !bound.agent.session.snapshotEvents().some(event => event.type === 'turn/start');
757
978
  const selected = ctx.sessionProjections.stateOf(bound.agent.session, 'agentPreset')
758
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]' };
759
998
  dispatch({
760
999
  type: 'open-overlay',
761
1000
  overlay: agentPresetOverlay(await ctx.agentPresets.list(), selected, blank),
@@ -1031,6 +1270,14 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
1031
1270
  }
1032
1271
  break;
1033
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
+ }
1034
1281
  case 'model': {
1035
1282
  const result = await ctx.sessionController.selectModel({
1036
1283
  sessionId: bound.agent.id, model: value.model, provider: value.provider,
@@ -1068,8 +1315,9 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
1068
1315
  initialAttachments,
1069
1316
  get initialCells() { return transcriptCells(current()); },
1070
1317
  get initialHasMore() { return current().hasMore; },
1071
- get initialPrompts() { return humanPrompts(current().events); },
1318
+ get initialPrompts() { return historyInputs(current().events); },
1072
1319
  get root() { return current().root; },
1320
+ get statusLine() { const bound = current(); return sessionStatusLine(ctx, bound.agent, bound.root, bound.repo); },
1073
1321
  get summary() { return `Resume with: dsh --profile dashi --resume ${current().root.id}`; },
1074
1322
  activate,
1075
1323
  async attach(path) {
@@ -1218,6 +1466,7 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
1218
1466
  bound.agent.inject(message);
1219
1467
  await ctx.sessions.flush(bound.agent.session);
1220
1468
  });
1469
+ dispatch({ type: 'prompt-recorded', rootId: bound.root.id, text: `!${command}` });
1221
1470
  publish(bound);
1222
1471
  return;
1223
1472
  }
@@ -7,4 +7,4 @@ export declare const HUMAN_SHELL_STREAM_BYTES: number;
7
7
  export declare function shellResultText(command: string, result: ShellRunResult): string;
8
8
  export declare function quoteShellWord(value: string): string;
9
9
  /** Run an explicit human command through DSH and identify its next-step context. */
10
- export declare function runHumanShell(ctx: Context, agent: Agent, command: string, signal: AbortSignal, success?: string): Promise<readonly [UserMessage, ShellRunResult]>;
10
+ export declare function runHumanShell(ctx: Context, agent: Agent, command: string, signal: AbortSignal, success?: string, useSessionSandbox?: boolean): Promise<readonly [UserMessage, ShellRunResult]>;
@@ -45,11 +45,12 @@ export function shellResultText(command, result) {
45
45
  }
46
46
  export function quoteShellWord(value) { return `'${value.replaceAll("'", "'\\''")}'`; }
47
47
  /** Run an explicit human command through DSH and identify its next-step context. */
48
- export async function runHumanShell(ctx, agent, command, signal, success) {
48
+ export async function runHumanShell(ctx, agent, command, signal, success, useSessionSandbox = true) {
49
49
  const result = await ctx.shell.run(ctx.shell.resolve({
50
50
  command,
51
51
  env: { DSH_HOME: resolveDshHome() },
52
- sandboxPolicy: ctx.sandboxPolicy.resolve({ session: agent.session }),
52
+ sandboxPolicy: ctx.sandboxPolicy.resolve(useSessionSandbox
53
+ ? { session: agent.session } : { mode: 'danger-full-access' }),
53
54
  signal,
54
55
  stdoutMaxBytes: HUMAN_SHELL_STREAM_BYTES,
55
56
  timeoutMs: HUMAN_SHELL_TIMEOUT_MS,
package/lib/state.d.ts CHANGED
@@ -137,6 +137,9 @@ export type OverlayValue = {
137
137
  } | {
138
138
  readonly kind: 'insert';
139
139
  readonly text: string;
140
+ } | {
141
+ readonly jobId: string;
142
+ readonly kind: 'job-output';
140
143
  } | {
141
144
  readonly kind: 'model';
142
145
  readonly effort?: string;
@@ -213,6 +216,7 @@ export type Overlay = {
213
216
  readonly cells?: readonly TerminalCell[];
214
217
  readonly kind: 'info';
215
218
  readonly lines: readonly string[];
219
+ readonly scrollOffset?: number;
216
220
  readonly title: string;
217
221
  } | {
218
222
  readonly cursor: number;
@@ -268,6 +272,9 @@ export type UiAction = {
268
272
  readonly source?: string;
269
273
  readonly text?: string;
270
274
  readonly type: 'attachment-added';
275
+ } | {
276
+ readonly path: string;
277
+ readonly type: 'attachment-path';
271
278
  } | {
272
279
  readonly index: number;
273
280
  readonly type: 'attachment-remove';
@@ -330,6 +337,9 @@ export type UiAction = {
330
337
  readonly type: 'open-history';
331
338
  } | {
332
339
  readonly type: 'open-details';
340
+ } | {
341
+ readonly path: string;
342
+ readonly type: 'open-file';
333
343
  } | {
334
344
  readonly type: 'completion-ready';
335
345
  readonly options: readonly OverlayOption[];
@@ -340,6 +350,7 @@ export type UiAction = {
340
350
  } | {
341
351
  readonly type: 'overlay-close';
342
352
  } | {
353
+ readonly limit?: number;
343
354
  readonly type: 'overlay-move';
344
355
  readonly offset: number;
345
356
  } | {
@@ -500,6 +511,7 @@ export type UiEffect = {
500
511
  } | {
501
512
  readonly type: 'suspend';
502
513
  };
503
- export declare function initialViewState(cwd: string, inline: boolean, root?: RootView, cells?: readonly TerminalCell[], prompts?: readonly string[], historyHasMore?: boolean, attachments?: readonly DraftAttachment[]): ViewState;
514
+ export declare function initialViewState(cwd: string, inline: boolean, root?: RootView, cells?: readonly TerminalCell[], prompts?: readonly string[], historyHasMore?: boolean, attachments?: readonly DraftAttachment[], toolMode?: ToolMode): ViewState;
504
515
  export declare const TURN_BELL_THRESHOLD_MS = 10000;
516
+ export declare const PAGE_CELLS = 8;
505
517
  export declare function reduce(state: ViewState, action: UiAction): readonly [ViewState, readonly UiEffect[]];