dsh-code 0.7.0 → 0.9.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.
Files changed (49) hide show
  1. package/README.en.md +30 -7
  2. package/README.md +30 -7
  3. package/lib/index.mjs +3791 -853
  4. package/lib/types/app.d.ts +90 -1
  5. package/lib/types/approval.d.ts +3 -1
  6. package/lib/types/history.d.ts +15 -4
  7. package/lib/types/index.d.ts +48 -0
  8. package/lib/types/kernel-panels.d.ts +65 -8
  9. package/lib/types/models.d.ts +15 -1
  10. package/lib/types/permissions.d.ts +37 -0
  11. package/lib/types/presets.d.ts +2 -0
  12. package/lib/types/provider-settings.d.ts +144 -0
  13. package/lib/types/questions.d.ts +2 -0
  14. package/lib/types/render/animations.d.ts +8 -6
  15. package/lib/types/render/lines.d.ts +6 -0
  16. package/lib/types/render/markdown.d.ts +3 -3
  17. package/lib/types/render/projection.d.ts +97 -3
  18. package/lib/types/render/status.d.ts +26 -36
  19. package/lib/types/render/text.d.ts +14 -7
  20. package/lib/types/render/tool-detail.d.ts +3 -1
  21. package/lib/types/render/tool-preview.d.ts +14 -1
  22. package/lib/types/session-directory.d.ts +61 -2
  23. package/lib/types/store.d.ts +13 -2
  24. package/lib/types/subagents.d.ts +60 -0
  25. package/lib/types/version.d.ts +5 -0
  26. package/package.json +1 -1
  27. package/src/app.ts +1200 -219
  28. package/src/approval.ts +161 -126
  29. package/src/history.ts +20 -5
  30. package/src/index.ts +577 -167
  31. package/src/kernel-panels.ts +354 -37
  32. package/src/models.ts +26 -0
  33. package/src/permissions.ts +85 -0
  34. package/src/presets.ts +12 -0
  35. package/src/provider-settings.ts +520 -0
  36. package/src/questions.ts +15 -5
  37. package/src/render/animations.ts +32 -18
  38. package/src/render/lines.ts +236 -218
  39. package/src/render/markdown.ts +302 -4
  40. package/src/render/projection.ts +670 -11
  41. package/src/render/status.ts +68 -162
  42. package/src/render/text.ts +28 -9
  43. package/src/render/tool-detail.ts +81 -40
  44. package/src/render/tool-preview.ts +77 -34
  45. package/src/session-directory.ts +171 -10
  46. package/src/skills.ts +8 -4
  47. package/src/store.ts +26 -8
  48. package/src/subagents.ts +165 -0
  49. package/src/version.ts +16 -0
package/src/approval.ts CHANGED
@@ -1,126 +1,161 @@
1
- /**
2
- * The terminal approval answerer: one `approval/request` waterfall listener
3
- * that renders the pending question as a y/n bar and resolves the decision
4
- * back into the waterfall. Mirrors the web host's composer takeover — the
5
- * service (audit pair, policy gate, fail-closed defaults) all live in
6
- * dsh-base; this module only answers for agents this TUI owns.
7
- *
8
- * Vocabulary note: a client answerer may only ever resolve `'allowed-once'`
9
- * or `'rejected'`; `'cancelled'` belongs to the request signal and
10
- * `'unavailable'` to the fail-closed waterfall default.
11
- *
12
- * @module @deepseek-ai/dsh-code/approval
13
- */
14
-
15
- import type { Context } from '@deepseek-ai/cordis'
16
- import type { Agent } from '@deepseek-ai/dsh-agent'
17
- import type { ApprovalOutcome, ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
18
-
19
- /** The answer values a client answerer may resolve with. */
20
- export type ApprovalAnswer = 'allowed-once' | 'rejected'
21
-
22
- /** One pending approval question, derived from the request for rendering. */
23
- export interface PendingApproval {
24
- /** The asker's human-readable explanation, or a generic fallback. */
25
- headline: string
26
- /** The tool the question is about. */
27
- toolName: string
28
- /** Command-line preview resolved from the paired streaming tool call. */
29
- command: string
30
- /** Resolve the ask; calling twice is inert (one-shot latch). */
31
- answer(outcome: ApprovalAnswer): void
32
- }
33
-
34
- /** The pending-question snapshot the renderer subscribes to. */
35
- export interface ApprovalSnapshot {
36
- /** The pending question, or undefined when none is being asked. */
37
- pending: PendingApproval | undefined
38
- /** Presentational: an answer was submitted, the ask has not settled yet. */
39
- answered: boolean
40
- }
41
-
42
- /** Store the pending question lands in; the renderer reads, the answerer writes. */
43
- export interface ApprovalStore {
44
- /** Subscribe to pending-state changes; returns the unsubscribe function. */
45
- subscribe(listener: () => void): () => void
46
- /** Read the current snapshot (identity-stable between changes). */
47
- getSnapshot(): ApprovalSnapshot
48
- }
49
-
50
- /**
51
- * Create the approval store and mount the answerer listener on the context.
52
- * The listener claims only requests for `owns`-owned agents and defers every
53
- * other request back into the waterfall (`next()`), so sibling answerers stay
54
- * usable. An aborted ask never reaches the human. Plugin teardown removes the
55
- * listener; the service then fails its own question closed.
56
- * @param ctx - plugin context whose event bus carries `approval/request`.
57
- * @param owns - agents this terminal answers for.
58
- * @param preview - resolves a tool-call preview for a pending request (the
59
- * request contract carries no arguments; the UI self-serves from the
60
- * transcript projection via `callId`).
61
- * @returns the store the renderer subscribes to.
62
- */
63
- export function mountApprovalAnswerer(
64
- ctx: Context,
65
- owns: (agent: Agent) => boolean,
66
- preview: (request: ApprovalRequest) => string,
67
- ): ApprovalStore {
68
- let snapshot: ApprovalSnapshot = { pending: undefined, answered: false }
69
- const listeners = new Set<() => void>()
70
- const set = (next: ApprovalSnapshot): void => {
71
- snapshot = next
72
- for (const listener of listeners) listener()
73
- }
74
-
75
- ctx.on('approval/request', (request: ApprovalRequest, next: () => Promise<ApprovalOutcome>) => {
76
- if (!owns(request.agent)) return next()
77
- // An already-aborted ask never reaches the human (mirrors the host bridge).
78
- if (request.signal?.aborted === true) return Promise.resolve<ApprovalOutcome>('cancelled')
79
-
80
- let resolved = false
81
- let settle!: (outcome: ApprovalOutcome) => void
82
- const withdraw = (): void => {
83
- if (resolved) return
84
- resolved = true
85
- set({ pending: undefined, answered: false })
86
- // The service's signal race would conclude 'cancelled' anyway; settle
87
- // the same way so this listener never dangles a pending promise.
88
- settle('cancelled')
89
- }
90
- if (request.signal !== undefined) {
91
- request.signal.addEventListener('abort', withdraw, { once: true })
92
- }
93
- const pending: PendingApproval = {
94
- headline: request.reason ?? `tool ${request.toolName} asks for your approval`,
95
- toolName: request.toolName,
96
- command: preview(request),
97
- answer: (outcome: ApprovalAnswer): void => {
98
- // One-shot latch: a second keypress after submission is inert.
99
- if (resolved) return
100
- resolved = true
101
- set({ pending, answered: true })
102
- settle(outcome)
103
- },
104
- }
105
- set({ pending, answered: false })
106
-
107
- return new Promise<ApprovalOutcome>((resolve) => {
108
- settle = resolve
109
- }).then((outcome) => {
110
- if (outcome !== 'cancelled') set({ pending: undefined, answered: false })
111
- return outcome
112
- })
113
- })
114
-
115
- return {
116
- subscribe(listener: () => void): () => void {
117
- listeners.add(listener)
118
- return () => {
119
- listeners.delete(listener)
120
- }
121
- },
122
- getSnapshot(): ApprovalSnapshot {
123
- return snapshot
124
- },
125
- }
126
- }
1
+ /**
2
+ * The terminal approval answerer: one `approval/request` waterfall listener
3
+ * that renders the pending question as a y/n bar and resolves the decision
4
+ * back into the waterfall. Mirrors the web host's composer takeover — the
5
+ * service (audit pair, policy gate, fail-closed defaults) all live in
6
+ * dsh-base; this module only answers for agents this TUI owns.
7
+ *
8
+ * Vocabulary note: a client answerer may only ever resolve `'allowed-once'`
9
+ * or `'rejected'`; `'cancelled'` belongs to the request signal and
10
+ * `'unavailable'` to the fail-closed waterfall default.
11
+ *
12
+ * @module @deepseek-ai/dsh-code/approval
13
+ */
14
+
15
+ import type { Context } from '@deepseek-ai/cordis'
16
+ import type { Agent } from '@deepseek-ai/dsh-agent'
17
+ import type { ApprovalOutcome, ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
18
+
19
+ /** The answer values a client answerer may resolve with. */
20
+ export type ApprovalAnswer = 'allowed-once' | 'rejected'
21
+
22
+ /** One pending approval question, derived from the request for rendering. */
23
+ export interface PendingApproval {
24
+ /** The asker's human-readable explanation, or a generic fallback. */
25
+ headline: string
26
+ /** The tool the question is about. */
27
+ toolName: string
28
+ /** Command-line preview resolved from the paired streaming tool call. */
29
+ command: string
30
+ /** Resolve the ask; calling twice is inert (one-shot latch). */
31
+ answer(outcome: ApprovalAnswer): void
32
+ }
33
+
34
+ /** The pending-question snapshot the renderer subscribes to. */
35
+ export interface ApprovalSnapshot {
36
+ /** The question on screen (queue head), or undefined when none is asked. */
37
+ pending: PendingApproval | undefined
38
+ /** Presentational: an answer was submitted, the ask has not settled yet. */
39
+ answered: boolean
40
+ /** Further asks waiting behind the on-screen one (FIFO, Codex-style). */
41
+ queued: number
42
+ }
43
+
44
+ /** Store the pending question lands in; the renderer reads, the answerer writes. */
45
+ export interface ApprovalStore {
46
+ /** Subscribe to pending-state changes; returns the unsubscribe function. */
47
+ subscribe(listener: () => void): () => void
48
+ /** Read the current snapshot (identity-stable between changes). */
49
+ getSnapshot(): ApprovalSnapshot
50
+ }
51
+
52
+ /**
53
+ * Create the approval store and mount the answerer listener on the context.
54
+ * The listener claims only requests for `owns`-owned agents and defers every
55
+ * other request back into the waterfall (`next()`), so sibling answerers stay
56
+ * usable. An aborted ask never reaches the human. Plugin teardown removes the
57
+ * listener; the service then fails its own question closed.
58
+ * @param ctx - plugin context whose event bus carries `approval/request`.
59
+ * @param owns - agents this terminal answers for.
60
+ * @param preview - resolves a tool-call preview for a pending request (the
61
+ * request contract carries no arguments; the UI self-serves from the
62
+ * transcript projection via `callId`).
63
+ * @returns the store the renderer subscribes to.
64
+ */
65
+ export function mountApprovalAnswerer(
66
+ ctx: Context,
67
+ owns: (agent: Agent) => boolean,
68
+ preview: (request: ApprovalRequest) => string,
69
+ ): ApprovalStore {
70
+ /** One live ask: its pending view plus the one-shot settle plumbing. */
71
+ interface Slot {
72
+ readonly pending: PendingApproval
73
+ answered: boolean
74
+ }
75
+ const queue: Slot[] = []
76
+ let snapshot: ApprovalSnapshot = { pending: undefined, answered: false, queued: 0 }
77
+ const listeners = new Set<() => void>()
78
+ const publish = (): void => {
79
+ const head = queue[0]
80
+ snapshot = {
81
+ pending: head === undefined ? undefined : head.pending,
82
+ answered: head !== undefined && head.answered,
83
+ queued: Math.max(0, queue.length - 1),
84
+ }
85
+ for (const listener of listeners) listener()
86
+ }
87
+ const removeSlot = (slot: Slot): void => {
88
+ const at = queue.indexOf(slot)
89
+ if (at !== -1) queue.splice(at, 1)
90
+ }
91
+
92
+ ctx.on('approval/request', (request: ApprovalRequest, next: () => Promise<ApprovalOutcome>) => {
93
+ if (!owns(request.agent)) return next()
94
+ // An already-aborted ask never reaches the human (mirrors the host bridge).
95
+ if (request.signal?.aborted === true) return Promise.resolve<ApprovalOutcome>('cancelled')
96
+
97
+ let resolved = false
98
+ let settle!: (outcome: ApprovalOutcome) => void
99
+ const signal = request.signal
100
+ const onAbort = (): void => withdraw()
101
+ // Detach on every settle so an answered ask never retains a listener on
102
+ // the tool call's signal (long turns ask many times; each ask must let go).
103
+ const detachAbort = (): void => {
104
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort)
105
+ }
106
+ const withdraw = (): void => {
107
+ if (resolved) return
108
+ resolved = true
109
+ detachAbort()
110
+ removeSlot(slot)
111
+ publish()
112
+ // The service's signal race would conclude 'cancelled' anyway; settle
113
+ // the same way so this listener never dangles a pending promise.
114
+ settle('cancelled')
115
+ }
116
+ if (signal !== undefined) {
117
+ signal.addEventListener('abort', onAbort, { once: true })
118
+ }
119
+ const slot: Slot = {
120
+ answered: false,
121
+ pending: {
122
+ headline: request.reason ?? `tool ${request.toolName} asks for your approval`,
123
+ toolName: request.toolName,
124
+ command: preview(request),
125
+ answer: (outcome: ApprovalAnswer): void => {
126
+ // One-shot latch: a second keypress after submission is inert.
127
+ if (resolved) return
128
+ resolved = true
129
+ detachAbort()
130
+ slot.answered = true
131
+ publish()
132
+ settle(outcome)
133
+ },
134
+ },
135
+ }
136
+ queue.push(slot)
137
+ publish()
138
+
139
+ return new Promise<ApprovalOutcome>((resolve) => {
140
+ settle = resolve
141
+ }).then((outcome) => {
142
+ if (outcome !== 'cancelled') {
143
+ removeSlot(slot)
144
+ publish()
145
+ }
146
+ return outcome
147
+ })
148
+ })
149
+
150
+ return {
151
+ subscribe(listener: () => void): () => void {
152
+ listeners.add(listener)
153
+ return () => {
154
+ listeners.delete(listener)
155
+ }
156
+ },
157
+ getSnapshot(): ApprovalSnapshot {
158
+ return snapshot
159
+ },
160
+ }
161
+ }
package/src/history.ts CHANGED
@@ -8,8 +8,8 @@
8
8
  * @module @deepseek-ai/dsh-tui/history
9
9
  */
10
10
 
11
- /** Maximum entries retained in the persistent history file. */
12
- export const HISTORY_MAX_ENTRIES = 500
11
+ /** Maximum entries retained in the persistent history file and the local recall pool. */
12
+ export const HISTORY_MAX_ENTRIES = 100
13
13
 
14
14
  /** Encode one entry for the history file (JSON keeps multi-line drafts intact). */
15
15
  export function serializeHistoryEntry(text: string): string {
@@ -56,15 +56,30 @@ export function appendHistoryContent(current: string, text: string, max = HISTOR
56
56
 
57
57
  /**
58
58
  * Record one in-session submission: empty text is ignored and an adjacent
59
- * duplicate collapses (Codex `record_local_submission` semantics).
59
+ * duplicate collapses (Codex `record_local_submission` semantics). The local
60
+ * pool shares the persistent pool's cap so the recall space stays bounded.
60
61
  * @param local - current in-session entries, oldest first.
61
62
  * @param text - the submitted prompt.
63
+ * @param max - the local pool cap.
62
64
  * @returns the updated local list.
63
65
  */
64
- export function recordLocalEntry(local: readonly string[], text: string): readonly string[] {
66
+ export function recordLocalEntry(local: readonly string[], text: string, max = HISTORY_MAX_ENTRIES): readonly string[] {
65
67
  if (text === '') return local
66
68
  if (local.length > 0 && local[local.length - 1] === text) return local
67
- return [...local, text]
69
+ return [...local, text].slice(-max)
70
+ }
71
+
72
+ /**
73
+ * Serialize a capped entry list to the history file format (one JSON line per
74
+ * entry, trailing newline). The runner writes the in-memory list as the whole
75
+ * file, so rapid same-process submissions cannot lose entries to a
76
+ * read-modify-write race (the file is never read back before writing).
77
+ * @param entries - the entries to persist, oldest first.
78
+ * @returns the file content, '' for an empty list.
79
+ */
80
+ export function serializeHistoryList(entries: readonly string[]): string {
81
+ if (entries.length === 0) return ''
82
+ return entries.map(serializeHistoryEntry).join('\n') + '\n'
68
83
  }
69
84
 
70
85
  /**