dsh-code 0.4.0 → 0.5.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.
@@ -0,0 +1,58 @@
1
+ /** Latest-wins, idle-bound queue for safe Agent session changes. */
2
+
3
+ export interface IdleActivity {
4
+ readonly status: 'idle' | 'running'
5
+ whenIdle(): Promise<void>
6
+ }
7
+
8
+ interface Request<T> {
9
+ readonly activity: IdleActivity
10
+ readonly value: T
11
+ }
12
+
13
+ export class SessionSwitchQueue<T> {
14
+ private pending: Request<T> | undefined
15
+ private pumping = false
16
+
17
+ constructor(
18
+ private readonly execute: (value: T) => Promise<void>,
19
+ private readonly failed: (error: unknown) => void,
20
+ ) {}
21
+
22
+ /** Queue a request; a later request replaces any request still waiting. */
23
+ request(activity: IdleActivity, value: T): 'queued' | 'started' {
24
+ this.pending = { activity, value }
25
+ const outcome = activity.status === 'running' || this.pumping ? 'queued' : 'started'
26
+ if (!this.pumping) void this.pump()
27
+ return outcome
28
+ }
29
+
30
+ /** Cancel only work that has not begun activation. */
31
+ cancel(): boolean {
32
+ if (this.pending === undefined) return false
33
+ this.pending = undefined
34
+ return true
35
+ }
36
+
37
+ private async pump(): Promise<void> {
38
+ this.pumping = true
39
+ try {
40
+ while (this.pending !== undefined) {
41
+ const observed = this.pending
42
+ await observed.activity.whenIdle()
43
+ // Another request replaced this one while the turn was converging.
44
+ if (this.pending !== observed) continue
45
+ this.pending = undefined
46
+ try {
47
+ await this.execute(observed.value)
48
+ } catch (error: unknown) {
49
+ this.failed(error)
50
+ }
51
+ }
52
+ } finally {
53
+ this.pumping = false
54
+ // A request may land between the loop condition and finally.
55
+ if (this.pending !== undefined) void this.pump()
56
+ }
57
+ }
58
+ }
package/src/skills.ts CHANGED
@@ -28,6 +28,8 @@ export interface SkillRow {
28
28
  export interface SkillsView {
29
29
  /** Name-sorted user-invocable rows; empty until the first load lands. */
30
30
  readonly rows: readonly SkillRow[]
31
+ /** Latest catalog-read failure; the help panel exposes it in place. */
32
+ readonly error?: string
31
33
  /** Subscribe to catalog changes; returns the unsubscribe function. */
32
34
  subscribe(listener: () => void): () => void
33
35
  /** Retarget the agent whose workspace the catalog is read for. */
@@ -63,21 +65,29 @@ export function watchSkills(ctx: Context): SkillsWatch {
63
65
  const skills = ctx.get('skills')
64
66
  let agent: Agent | undefined
65
67
  let rows: readonly SkillRow[] = []
68
+ let error: string | undefined
66
69
  const listeners = new Set<() => void>()
67
70
 
68
71
  const reload = (): void => {
69
- if (skills === undefined || agent === undefined) return
70
- skills.list({
71
- cwd: agent.session.header.cwd,
72
- scope: agent,
73
- }).then((summaries: readonly SkillSummary[]) => {
72
+ const currentAgent = agent
73
+ if (skills === undefined || currentAgent === undefined) return
74
+ Promise.resolve().then(() => skills.list({
75
+ cwd: currentAgent.session.header.cwd,
76
+ scope: currentAgent,
77
+ })).then((summaries: readonly SkillSummary[]) => {
74
78
  const next = toRows(summaries)
75
- if (next.length === rows.length && next.every((row, index) => row.name === rows[index]?.name)) return
79
+ const unchanged = next.length === rows.length && next.every((row, index) => row.name === rows[index]?.name)
76
80
  rows = next
81
+ const recovered = error !== undefined
82
+ error = undefined
83
+ if (unchanged && !recovered) return
77
84
  for (const listener of listeners) listener()
78
- }, () => {
85
+ }).catch((cause: unknown) => {
79
86
  // Discovery failure keeps the last good rows; the next skills/change
80
87
  // notification is the retry surface (mirrors the web directory).
88
+ rows = [...rows]
89
+ error = cause instanceof Error ? cause.message : String(cause)
90
+ for (const listener of listeners) listener()
81
91
  })
82
92
  }
83
93
 
@@ -89,6 +99,9 @@ export function watchSkills(ctx: Context): SkillsWatch {
89
99
  get rows(): readonly SkillRow[] {
90
100
  return rows
91
101
  },
102
+ get error(): string | undefined {
103
+ return error
104
+ },
92
105
  subscribe(listener: () => void): () => void {
93
106
  listeners.add(listener)
94
107
  return () => {
package/src/startup.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The interactive terminal app's command-line provider: parses `--resume`,
3
- * `--continue`, `--session`, and `--help`, then publishes
3
+ * `--continue`, `--session`, `--mode`, and `--help`, then publishes
4
4
  * {@link TUI_STARTUP_SERVICE} for the runner to consume lazily. Follows the
5
5
  * headless bundle's startup shape (a commander action publishing a service
6
6
  * through {@link parseCmdline}).
@@ -33,11 +33,37 @@ export const TUI_STARTUP_SERVICE = 'tuiStartup'
33
33
 
34
34
  /** How the runner obtains its session identity. */
35
35
  export type TuiStartup =
36
- | { readonly kind: 'fresh' }
37
- | { readonly kind: 'named'; readonly sessionId: string }
36
+ | { readonly kind: 'fresh'; readonly mode?: string }
37
+ | { readonly kind: 'named'; readonly sessionId: string; readonly mode?: string }
38
38
  | { readonly kind: 'resume'; readonly sessionId: string }
39
39
  | { readonly kind: 'latest' }
40
40
 
41
+ export interface TuiStartupOptions {
42
+ readonly resume?: string
43
+ readonly continue?: boolean
44
+ readonly session?: string
45
+ readonly mode?: string
46
+ }
47
+
48
+ /** Pure option policy shared by Commander and tests. */
49
+ export function resolveTuiStartup(options: TuiStartupOptions): TuiStartup {
50
+ const selected = [options.resume !== undefined, options.continue === true, options.session !== undefined]
51
+ if (selected.filter(Boolean).length > 1) throw new Error('--resume, --continue, and --session are mutually exclusive')
52
+ if (options.session === '') throw new Error('--session needs an id')
53
+ if (options.resume === '') throw new Error('--resume needs a session id or id prefix')
54
+ if (options.mode === '') throw new Error('--mode needs a preset id')
55
+ if (options.mode !== undefined && (options.resume !== undefined || options.continue === true)) {
56
+ throw new Error('--mode applies only to a new session; it cannot be combined with --resume or --continue')
57
+ }
58
+ return options.resume !== undefined
59
+ ? { kind: 'resume', sessionId: options.resume }
60
+ : options.continue === true
61
+ ? { kind: 'latest' }
62
+ : options.session !== undefined
63
+ ? { kind: 'named', sessionId: options.session, ...options.mode === undefined ? {} : { mode: options.mode } }
64
+ : { kind: 'fresh', ...options.mode === undefined ? {} : { mode: options.mode } }
65
+ }
66
+
41
67
  /**
42
68
  * This app's command: the launcher's flags this app owns, its description,
43
69
  * and its help text.
@@ -51,11 +77,13 @@ function tuiCommand(): Command {
51
77
  .option('-r, --resume <session>', 'resume the persisted session with this id (or unique id prefix)')
52
78
  .option('-c, --continue', 'resume the most recent persisted session for this working directory')
53
79
  .option('--session <id>', 'create a new session under this explicit id')
80
+ .option('--mode <preset>', 'agent preset for a newly created session')
54
81
  .addHelpText('after', `
55
82
  Examples:
56
83
  dsh --profile cli fresh session, minted id
57
84
  dsh --profile cli --resume abc123 resume session by id prefix
58
85
  dsh --profile cli --continue resume the latest local session
86
+ dsh --profile cli --mode minimal fresh session using the minimal preset
59
87
  `)
60
88
  }
61
89
 
@@ -67,24 +95,14 @@ Examples:
67
95
  export function apply(ctx: Context): void {
68
96
  const program = tuiCommand()
69
97
  program.action(() => {
70
- const options = program.opts<{ resume?: string; continue?: boolean; session?: string }>()
71
- const selected = [options.resume !== undefined, options.continue === true, options.session !== undefined]
72
- if (selected.filter(Boolean).length > 1) {
73
- program.error('error: --resume, --continue, and --session are mutually exclusive')
74
- }
75
- if (options.session !== undefined && options.session === '') {
76
- program.error('error: --session needs an id')
77
- }
78
- if (options.resume !== undefined && options.resume === '') {
79
- program.error('error: --resume needs a session id or id prefix')
98
+ const options = program.opts<TuiStartupOptions>()
99
+ let startup: TuiStartup | undefined
100
+ try {
101
+ startup = resolveTuiStartup(options)
102
+ } catch (error: unknown) {
103
+ program.error(`error: ${error instanceof Error ? error.message : String(error)}`)
80
104
  }
81
- const startup: TuiStartup = options.resume !== undefined
82
- ? { kind: 'resume', sessionId: options.resume }
83
- : options.continue === true
84
- ? { kind: 'latest' }
85
- : options.session !== undefined
86
- ? { kind: 'named', sessionId: options.session }
87
- : { kind: 'fresh' }
105
+ if (startup === undefined) return
88
106
  ctx.provide(TUI_STARTUP_SERVICE, { startup } satisfies { startup: TuiStartup })
89
107
  })
90
108
  parseCmdline(ctx, program)
Binary file