dsh-code 0.3.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.
Files changed (46) hide show
  1. package/README.md +201 -55
  2. package/README.zh.md +204 -61
  3. package/bin/deepseek.mjs +70 -0
  4. package/cordis.patch.yml +62 -6
  5. package/lib/index.mjs +4073 -1802
  6. package/lib/startup.mjs +34 -17
  7. package/lib/types/app.d.ts +23 -22
  8. package/lib/types/commands.d.ts +2 -0
  9. package/lib/types/index.d.ts +5 -5
  10. package/lib/types/internals.d.ts +2 -0
  11. package/lib/types/kernel-panels.d.ts +23 -0
  12. package/lib/types/plugin-inventory.d.ts +11 -0
  13. package/lib/types/presets.d.ts +32 -0
  14. package/lib/types/render/export.d.ts +15 -0
  15. package/lib/types/render/inspector.d.ts +34 -0
  16. package/lib/types/render/lines.d.ts +31 -0
  17. package/lib/types/render/projection.d.ts +95 -3
  18. package/lib/types/render/status.d.ts +19 -0
  19. package/lib/types/render/text.d.ts +27 -0
  20. package/lib/types/render/tool-detail.d.ts +92 -0
  21. package/lib/types/session-directory.d.ts +54 -0
  22. package/lib/types/session-switch.d.ts +17 -0
  23. package/lib/types/skills.d.ts +2 -0
  24. package/lib/types/startup.d.ts +11 -1
  25. package/lib/types/store.d.ts +2 -0
  26. package/package.json +16 -1
  27. package/src/app.ts +1367 -277
  28. package/src/commands.ts +15 -1
  29. package/src/index.ts +373 -128
  30. package/src/internals.ts +5 -0
  31. package/src/kernel-panels.ts +254 -0
  32. package/src/plugin-inventory.ts +47 -0
  33. package/src/presets.ts +64 -0
  34. package/src/render/export.ts +81 -0
  35. package/src/render/inspector.ts +88 -0
  36. package/src/render/lines.ts +207 -0
  37. package/src/render/markdown.ts +15 -1
  38. package/src/render/projection.ts +279 -16
  39. package/src/render/status.ts +51 -1
  40. package/src/render/text.ts +107 -0
  41. package/src/render/tool-detail.ts +197 -0
  42. package/src/session-directory.ts +102 -0
  43. package/src/session-switch.ts +58 -0
  44. package/src/skills.ts +20 -7
  45. package/src/startup.ts +38 -20
  46. package/src/store.ts +8 -0
@@ -36,6 +36,18 @@ export function formatDuration(ms: number): string {
36
36
  return `${Math.floor(whole / 60)}m${whole % 60}s`
37
37
  }
38
38
 
39
+ /**
40
+ * Compact decode rate: one decimal under a hundred, whole below a thousand,
41
+ * then thousands (15.3 / 124 / 1.2K).
42
+ * @param n - tokens per second.
43
+ * @returns display string.
44
+ */
45
+ export function formatRate(n: number): string {
46
+ if (n < 100) return String(Math.round(n * 10) / 10)
47
+ if (n < 1_000) return String(Math.round(n))
48
+ return `${Math.round(n / 100) / 10}K`
49
+ }
50
+
39
51
  /**
40
52
  * Cache-hit share of billed prompt-side input.
41
53
  * @param usage - cumulative token totals.
@@ -51,12 +63,20 @@ export function cacheHitPercent(usage: TranscriptStats['usage']): number | null
51
63
  export interface StatusFacts {
52
64
  /** `provider/model` selection serving this session. */
53
65
  model: string
66
+ /** Agent preset composing this session. */
67
+ mode?: string
54
68
  /** Working-directory basename the session serves. */
55
69
  cwd: string
56
70
  /** Git branch name, empty outside a repository or on a detached HEAD file. */
57
71
  branch: string
58
72
  /** Short session identifier (last dash-separated segment or tail). */
59
73
  sessionId: string
74
+ /** Latest session title (folded from `session/title`); shown in place of the id. */
75
+ title: string
76
+ /** Sandbox-mode override (folded from `sandbox/mode`), empty when never switched. */
77
+ sandbox: string
78
+ /** Live goal summary (folded from `goal/change`), undefined when none. */
79
+ goal: { phase: string; rounds: number; max: number } | undefined
60
80
  /** Whether plan mode is active (folded from `plan/mode`). */
61
81
  plan: boolean
62
82
  /** Active permission preset (folded from `permission/preset`), empty when unknown. */
@@ -78,22 +98,52 @@ export function buildStatusGroups(facts: StatusFacts, stats: TranscriptStats): s
78
98
  facts.plan ? '⧉ plan' : undefined,
79
99
  ].filter(part => part !== undefined && part !== '')
80
100
  if (identity.length > 0) groups.push(identity.join(' · '))
101
+ if (facts.mode !== undefined && facts.mode !== '') groups.push(`mode ${facts.mode}`)
81
102
  if (stats.turns > 0 || stats.steps > 0) {
82
103
  groups.push(`T${stats.turns} · S${stats.steps}`)
83
104
  const durations: string[] = []
84
105
  if (stats.llmMs > 0) durations.push(`llm ${formatDuration(stats.llmMs)}`)
106
+ // Decode latency figures (the web StatsLine's TTFT and throughput):
107
+ // average first-token wait and tokens per second over timed steps.
108
+ if (stats.ttftSteps > 0) durations.push(`ttft ${formatDuration(stats.ttftMs / stats.ttftSteps)}`)
109
+ if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
110
+ durations.push(`${formatRate(stats.decodeTokens / (stats.decodeMs / 1_000))} tok/s`)
111
+ }
85
112
  if (stats.toolMs > 0) durations.push(`tool ${formatDuration(stats.toolMs)}`)
86
113
  if (durations.length > 0) groups.push(durations.join(' · '))
87
114
  }
88
115
  const cacheHit = cacheHitPercent(stats.usage)
89
116
  if (stats.usage.inputTokens > 0 || stats.usage.outputTokens > 0) {
90
117
  if (cacheHit !== null) groups.push(`cache ${cacheHit}%`)
118
+ // Context occupancy (the web StatsLine's meter): the most recent
119
+ // reported prompt size against the advertised route capacity.
120
+ if (stats.contextWindow > 0 && stats.lastPromptTokens > 0) {
121
+ groups.push(`ctx ${Math.min(999, Math.round(stats.lastPromptTokens / stats.contextWindow * 100))}%`)
122
+ }
91
123
  groups.push(`↑${formatTokens(stats.usage.inputTokens)} ↓${formatTokens(stats.usage.outputTokens)}`)
92
124
  }
93
- if (facts.sessionId !== '') groups.push(facts.sessionId)
125
+ // The session title replaces the bare short id whenever one has landed
126
+ // (user rename or provider generation), bounded so a long title cannot
127
+ // crowd out the rest of the line.
128
+ const label = facts.title !== undefined && facts.title !== ''
129
+ ? (facts.title.length > 48 ? `${facts.title.slice(0, 47)}…` : facts.title)
130
+ : facts.sessionId
131
+ if (label !== '') groups.push(label)
94
132
  // The permission preset trails the line: switching it changes only the
95
133
  // tail, so the left-aligned bar never shifts its other groups. Plain text,
96
134
  // the Claude-Code permission-mode display (no glyphs).
97
135
  if (facts.permission !== undefined && facts.permission !== '') groups.push(facts.permission)
136
+ // The sandbox override stays implicit when it merely echoes the preset —
137
+ // the badge exists to surface a divergence, not to duplicate the label.
138
+ const sandbox = facts.sandbox ?? ''
139
+ if (sandbox !== '' && sandbox.toLowerCase() !== facts.permission.toLowerCase()) {
140
+ groups.push(`sandbox ${sandbox}`)
141
+ }
142
+ // Goal badge: round progress while active, the phase otherwise.
143
+ if (facts.goal !== undefined) {
144
+ groups.push(facts.goal.phase === 'active'
145
+ ? `◎ r${facts.goal.rounds}/${facts.goal.max}`
146
+ : `◎ ${facts.goal.phase}`)
147
+ }
98
148
  return groups
99
149
  }
@@ -22,3 +22,110 @@ const CONTROL_ESCAPE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu
22
22
  export function displayText(text: string): string {
23
23
  return text.replace(CONTROL_ESCAPE, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
24
24
  }
25
+
26
+ /** Collapse external text to one terminal-safe logical row. */
27
+ export function singleLineText(text: string): string {
28
+ return displayText(text).replace(/\r?\n/gu, ' ↵ ').replace(/\t/gu, ' ')
29
+ }
30
+
31
+ /** Terminal-cell width matching the TUI's existing CJK-aware wrapping rule. */
32
+ function cellWidth(text: string): number {
33
+ let columns = 0
34
+ for (const char of text) {
35
+ columns += (char.codePointAt(0) ?? 0) > 0x2e7f ? 2 : 1
36
+ }
37
+ return columns
38
+ }
39
+
40
+ /**
41
+ * Truncate one display-safe row without ever exceeding its physical-column
42
+ * budget. The ellipsis is included inside the budget, matching Codex's popup
43
+ * truncation contract; the previous app-local helper appended it after the
44
+ * row was already full and could force an extra terminal wrap.
45
+ */
46
+ export function truncateColumns(text: string, columns: number): string {
47
+ const limit = Math.max(0, Math.floor(columns))
48
+ if (limit === 0) return ''
49
+ if (cellWidth(text) <= limit) return text
50
+
51
+ const contentLimit = limit - 1
52
+ let used = 0
53
+ let result = ''
54
+ for (const char of text) {
55
+ const width = cellWidth(char)
56
+ if (used + width > contentLimit) break
57
+ result += char
58
+ used += width
59
+ }
60
+ return `${result}…`
61
+ }
62
+
63
+ /** A display-safe suffix bounded by terminal rows and columns. */
64
+ export interface DisplayTail {
65
+ /** Sanitized suffix suitable for direct terminal rendering. */
66
+ text: string
67
+ /** Whether content before the returned suffix was omitted. */
68
+ truncated: boolean
69
+ }
70
+
71
+ /** Read one Unicode character immediately before `end`. */
72
+ function previousCharacter(text: string, end: number): { char: string; start: number } {
73
+ const last = text.charCodeAt(end - 1)
74
+ if (last >= 0xdc00 && last <= 0xdfff && end >= 2) {
75
+ const first = text.charCodeAt(end - 2)
76
+ if (first >= 0xd800 && first <= 0xdbff) {
77
+ return { char: text.slice(end - 2, end), start: end - 2 }
78
+ }
79
+ }
80
+ return { char: text.slice(end - 1, end), start: end - 1 }
81
+ }
82
+
83
+ /**
84
+ * Keep only the newest display-safe text that fits a terminal rectangle.
85
+ * The scan walks backward and stops as soon as the suffix is full, so a long
86
+ * reasoning stream does not rescan its entire accumulated prefix per chunk.
87
+ * Explicit newlines and terminal wrapping both consume rows.
88
+ * @param text - raw externally sourced text.
89
+ * @param columns - available terminal columns.
90
+ * @param rows - available terminal rows.
91
+ * @returns a sanitized bounded suffix and whether an earlier prefix was cut.
92
+ */
93
+ export function displayTail(text: string, columns: number, rows: number): DisplayTail {
94
+ const columnLimit = Math.max(1, Math.floor(columns))
95
+ const rowLimit = Math.max(1, Math.floor(rows))
96
+ const reversed: string[] = []
97
+ let row = 1
98
+ let used = 0
99
+ let end = text.length
100
+
101
+ while (end > 0) {
102
+ const previous = previousCharacter(text, end)
103
+ if (previous.char === '\n') {
104
+ if (row >= rowLimit) break
105
+ reversed.push('\n')
106
+ row += 1
107
+ used = 0
108
+ end = previous.start
109
+ continue
110
+ }
111
+
112
+ const safe = displayText(previous.char)
113
+ const width = cellWidth(safe)
114
+ if (used > 0 && used + width > columnLimit) {
115
+ if (row >= rowLimit) break
116
+ // Materialize the soft wrap. Ink otherwise reflows at word boundaries
117
+ // and can turn a cell-counted two-row suffix into three rendered rows.
118
+ reversed.push('\n')
119
+ row += 1
120
+ used = 0
121
+ }
122
+ const extraRows = Math.floor(Math.max(0, width - 1) / columnLimit)
123
+ if (row + extraRows > rowLimit) break
124
+ row += extraRows
125
+ reversed.push(safe)
126
+ used = extraRows === 0 ? used + width : width - extraRows * columnLimit
127
+ end = previous.start
128
+ }
129
+
130
+ return { text: reversed.reverse().join(''), truncated: end > 0 }
131
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Expansion payloads for tool cards (the Ctrl+O verbose transcript): the
3
+ * TUI-side consumption of the harness presentation contract. Mutation and
4
+ * read tools persist a structured `tool/result.meta` (`diffs`, read
5
+ * windows, web sources) exactly so a capable UI can replay richer cards than
6
+ * the model-facing text; this module narrows that opaque JSON defensively —
7
+ * mirroring the upstream validators — and pre-formats bounded, render-ready
8
+ * rows. Malformed or absent metadata always degrades to the bounded raw
9
+ * result text, never throws during replay.
10
+ *
11
+ * @module @deepseek-ai/dsh-code/render/tool-detail
12
+ */
13
+
14
+ /** Budgets keeping one expanded card bounded on a terminal. */
15
+ const MAX_DIFF_LINES = 200
16
+ const MAX_READ_LINES = 120
17
+ const MAX_SOURCES = 10
18
+ const MAX_RAW_CHARS = 6000
19
+ const MAX_LINE_COLUMNS = 240
20
+
21
+ /** One rendered diff row: removed, added, or shared context. */
22
+ export interface DiffLine {
23
+ /** '-' removed, '+' added, ' ' context. */
24
+ mark: '-' | '+' | ' '
25
+ /** The line text, truncated to the column budget. */
26
+ text: string
27
+ }
28
+
29
+ /** One file's bounded inline diff. */
30
+ export interface ToolDiff {
31
+ /** File path the change belongs to. */
32
+ path: string
33
+ /** Rendered rows in order; '-' block before the '+' block. */
34
+ lines: readonly DiffLine[]
35
+ /** True when the line budget cut the hunk. */
36
+ truncated: boolean
37
+ }
38
+
39
+ /** One numbered line of a read window. */
40
+ export interface ToolReadLine {
41
+ /** 1-based file line number. */
42
+ number: number
43
+ /** The line text, truncated to the column budget. */
44
+ text: string
45
+ }
46
+
47
+ /** One web-search source row. */
48
+ export interface ToolWebSource {
49
+ /** Source URL. */
50
+ url: string
51
+ /** Source title, when the provider returned one. */
52
+ title: string | undefined
53
+ /** Short excerpt, truncated to the column budget. */
54
+ snippet: string
55
+ }
56
+
57
+ /** The expansion payload a verbose tool card renders; a discriminated union. */
58
+ export type ToolDetail =
59
+ | { kind: 'diff'; diffs: readonly ToolDiff[] }
60
+ | { kind: 'read'; path: string; offset: number; lines: readonly ToolReadLine[]; totalLines: number; truncated: boolean }
61
+ | { kind: 'web-search'; sources: readonly ToolWebSource[]; truncated: boolean }
62
+ | { kind: 'web-fetch'; url: string; statusCode: number }
63
+ | { kind: 'raw'; text: string; truncated: boolean }
64
+
65
+ /** Truncate one line to the visible-column budget with an ellipsis marker. */
66
+ function clipLine(text: string): string {
67
+ return text.length > MAX_LINE_COLUMNS ? `${text.slice(0, MAX_LINE_COLUMNS - 1)}…` : text
68
+ }
69
+
70
+ /** Split text into lines, dropping the trailing empty element of a final newline. */
71
+ function toLines(text: string): string[] {
72
+ const split = text.split('\n')
73
+ return split.length > 0 && split[split.length - 1] === '' ? split.slice(0, -1) : split
74
+ }
75
+
76
+ /**
77
+ * Render one change as removed-then-added rows, hunked by common prefix and
78
+ * suffix. A null before-image (file create) renders as pure additions. The
79
+ * budget caps emitted rows and reports the cut, so a whole-file overwrite
80
+ * never floods the transcript.
81
+ * @param oldText - prior content, or null for a create.
82
+ * @param newText - content after the change.
83
+ * @param budget - maximum rows to emit.
84
+ * @returns the bounded rows and whether they were cut.
85
+ */
86
+ export function diffRows(oldText: string | null, newText: string, budget: number): { lines: readonly DiffLine[]; truncated: boolean } {
87
+ const oldLines = oldText === null ? [] : toLines(oldText)
88
+ const newLines = toLines(newText)
89
+ let prefix = 0
90
+ while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix += 1
91
+ let suffix = 0
92
+ while (
93
+ suffix < oldLines.length - prefix && suffix < newLines.length - prefix
94
+ && oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
95
+ ) suffix += 1
96
+ const removed = oldLines.slice(prefix, oldLines.length - suffix)
97
+ const added = newLines.slice(prefix, newLines.length - suffix)
98
+ const rows: DiffLine[] = [
99
+ ...removed.map((text): DiffLine => ({ mark: '-', text: clipLine(text) })),
100
+ ...added.map((text): DiffLine => ({ mark: '+', text: clipLine(text) })),
101
+ ]
102
+ if (rows.length <= budget) return { lines: rows, truncated: false }
103
+ return { lines: rows.slice(0, budget), truncated: true }
104
+ }
105
+
106
+ /** Whether `value` is a valid upstream FileDiff (defensive narrowing). */
107
+ function isFileDiff(value: unknown): value is { path: string; oldText: string | null; newText: string } {
108
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
109
+ const { path, oldText, newText } = value as Record<string, unknown>
110
+ return typeof path === 'string' && (oldText === null || typeof oldText === 'string') && typeof newText === 'string'
111
+ }
112
+
113
+ /** Whether `value` is a valid read-window line (defensive narrowing). */
114
+ function isReadLine(value: unknown): value is { number: number; text: string } {
115
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
116
+ const { number, text } = value as Record<string, unknown>
117
+ return typeof number === 'number' && Number.isInteger(number) && number >= 1 && typeof text === 'string'
118
+ }
119
+
120
+ /** Whether `value` is a valid web source (defensive narrowing). */
121
+ function isWebSource(value: unknown): value is { url: string; title?: unknown; snippet?: unknown } {
122
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
123
+ const { url, title, snippet } = value as Record<string, unknown>
124
+ return typeof url === 'string'
125
+ && (title === undefined || typeof title === 'string')
126
+ && (snippet === undefined || typeof snippet === 'string')
127
+ }
128
+
129
+ /**
130
+ * Narrow the opaque `tool/result.meta` into one bounded expansion payload,
131
+ * mirroring the upstream presenters' degradation ladder: diffs (write/edit),
132
+ * read windows (read), sources (web_search), fetch summaries (web_fetch), and
133
+ * the bounded raw result text as the universal fallback.
134
+ * @param meta - the persisted presentation metadata, when the tool attached one.
135
+ * @param rawText - the joined text blocks of the result message.
136
+ * @returns the expansion payload, or undefined when nothing renderable exists.
137
+ */
138
+ export function toolResultDetail(meta: unknown, rawText: string): ToolDetail | undefined {
139
+ if (typeof meta === 'object' && meta !== null && !Array.isArray(meta)) {
140
+ const record = meta as Record<string, unknown>
141
+
142
+ const diffs = record['diffs']
143
+ if (Array.isArray(diffs) && diffs.length > 0 && diffs.every(isFileDiff)) {
144
+ const budget = Math.max(8, Math.floor(MAX_DIFF_LINES / diffs.length))
145
+ return {
146
+ kind: 'diff',
147
+ diffs: diffs.map(diff => ({
148
+ path: diff.path,
149
+ ...diffRows(diff.oldText, diff.newText, budget),
150
+ })),
151
+ }
152
+ }
153
+
154
+ const { path, offset, lines, totalLines } = record
155
+ if (typeof path === 'string' && Number.isInteger(offset) && (offset as number) >= 1
156
+ && Number.isInteger(totalLines) && (totalLines as number) >= 0
157
+ && Array.isArray(lines) && lines.every(isReadLine)) {
158
+ const window = lines as { number: number; text: string }[]
159
+ const truncated = window.length > MAX_READ_LINES
160
+ return {
161
+ kind: 'read',
162
+ path,
163
+ offset: offset as number,
164
+ lines: (truncated ? window.slice(0, MAX_READ_LINES) : window)
165
+ .map(line => ({ number: line.number, text: clipLine(line.text) })),
166
+ totalLines: totalLines as number,
167
+ truncated,
168
+ }
169
+ }
170
+
171
+ const sources = record['sources']
172
+ if (Array.isArray(sources) && sources.every(isWebSource)) {
173
+ const truncated = sources.length > MAX_SOURCES
174
+ return {
175
+ kind: 'web-search',
176
+ sources: (truncated ? sources.slice(0, MAX_SOURCES) : sources).map(source => ({
177
+ url: source.url,
178
+ title: typeof source.title === 'string' ? source.title : undefined,
179
+ snippet: typeof source.snippet === 'string' ? clipLine(source.snippet) : '',
180
+ })),
181
+ truncated,
182
+ }
183
+ }
184
+
185
+ const { url, statusCode } = record
186
+ if (typeof url === 'string' && typeof statusCode === 'number') {
187
+ return { kind: 'web-fetch', url, statusCode: Math.trunc(statusCode) }
188
+ }
189
+ }
190
+ if (rawText === '') return undefined
191
+ const truncated = rawText.length > MAX_RAW_CHARS
192
+ return {
193
+ kind: 'raw',
194
+ text: truncated ? rawText.slice(0, MAX_RAW_CHARS) : rawText,
195
+ truncated,
196
+ }
197
+ }
@@ -0,0 +1,102 @@
1
+ /** Lightweight session-directory projection for the /resume picker. */
2
+
3
+ import { basename, resolve } from 'node:path'
4
+ import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
5
+
6
+ export interface SessionRecord {
7
+ readonly header: SessionHeader
8
+ readonly live: boolean
9
+ readonly persisted: boolean
10
+ }
11
+
12
+ export interface TitleObservationResult {
13
+ readonly sessionId: string
14
+ readonly status: 'fulfilled' | 'rejected'
15
+ readonly value?: { readonly title?: { readonly title?: string; readonly text?: string } }
16
+ }
17
+
18
+ export interface SessionLogSnapshot {
19
+ readonly session: SessionHeader
20
+ readonly events: SessionEvent[]
21
+ }
22
+
23
+ /** Structural upstream SessionQuery surface used by the TUI. */
24
+ export interface SessionQueryService {
25
+ listSessions(signal?: AbortSignal): Promise<SessionRecord[]>
26
+ readTitleSnapshots(ids: readonly string[], signal?: AbortSignal): Promise<TitleObservationResult[]>
27
+ readSession(id: string, signal?: AbortSignal): Promise<SessionLogSnapshot>
28
+ }
29
+
30
+ export type SessionScope = 'roots' | 'all'
31
+ export type CwdScope = 'all' | 'current'
32
+ export type SessionSort = 'newest' | 'oldest'
33
+
34
+ export interface SessionDirectoryOptions {
35
+ readonly sessions: SessionScope
36
+ readonly cwd: CwdScope
37
+ readonly sort: SessionSort
38
+ readonly currentCwd: string
39
+ readonly query: string
40
+ }
41
+
42
+ export interface SessionRow {
43
+ readonly id: string
44
+ readonly createdAt: number
45
+ readonly cwd: string
46
+ readonly workspace: string
47
+ readonly parent?: string
48
+ readonly subagent: boolean
49
+ readonly resumable: boolean
50
+ readonly live: boolean
51
+ readonly persisted: boolean
52
+ readonly preset: string
53
+ readonly title?: string
54
+ }
55
+
56
+ function samePath(left: string | undefined, right: string): boolean {
57
+ if (left === undefined) return false
58
+ return resolve(left).toLowerCase() === resolve(right).toLowerCase()
59
+ }
60
+
61
+ /** Filter/sort header-only records. No session log is loaded here. */
62
+ export function projectSessionRows(records: readonly SessionRecord[], options: SessionDirectoryOptions): SessionRow[] {
63
+ const needle = options.query.trim().toLowerCase()
64
+ return records
65
+ .filter(record => options.sessions === 'all'
66
+ || (record.header.parentSession === undefined && record.header.origin !== 'subagent'))
67
+ .filter(record => options.cwd === 'all' || samePath(record.header.cwd, options.currentCwd))
68
+ .map(record => {
69
+ const cwd = record.header.cwd ?? ''
70
+ const subagent = record.header.origin === 'subagent' || record.header.parentSession !== undefined
71
+ return {
72
+ id: record.header.id,
73
+ createdAt: record.header.createdAt,
74
+ cwd,
75
+ workspace: cwd === '' ? '(no workspace)' : basename(cwd),
76
+ parent: record.header.parentSession,
77
+ subagent,
78
+ resumable: !subagent,
79
+ live: record.live,
80
+ persisted: record.persisted,
81
+ preset: record.header.agentPreset ?? 'standard',
82
+ }
83
+ })
84
+ .filter(row => needle === '' || `${row.id} ${row.cwd} ${row.workspace} ${row.preset}`.toLowerCase().includes(needle))
85
+ .sort((left, right) => options.sort === 'newest'
86
+ ? right.createdAt - left.createdAt
87
+ : left.createdAt - right.createdAt)
88
+ }
89
+
90
+ /** Merge page-local title observations without disturbing directory order. */
91
+ export function mergeSessionTitles(
92
+ rows: readonly SessionRow[],
93
+ observations: readonly TitleObservationResult[],
94
+ ): SessionRow[] {
95
+ const titles = new Map<string, string>()
96
+ for (const observation of observations) {
97
+ if (observation.status !== 'fulfilled') continue
98
+ const title = observation.value?.title?.title ?? observation.value?.title?.text
99
+ if (title !== undefined && title.trim() !== '') titles.set(observation.sessionId, title)
100
+ }
101
+ return rows.map(row => titles.has(row.id) ? { ...row, title: titles.get(row.id) } : row)
102
+ }
@@ -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 () => {