@linxin666/dsh-client-ui-task-board 0.3.23 → 0.4.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.
- package/icon.svg +3 -0
- package/lib/client.js +351 -85
- package/lib/client.js.map +1 -1
- package/lib/index.js +174 -916
- package/lib/types/client/PluginSettingsCard.d.ts +4 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +5 -5
- package/lib/types/client/board/TaskForm.d.ts +6 -1
- package/lib/types/client/board/parse-model-pref.d.ts +4 -0
- package/lib/types/client/index.d.ts +43 -10
- package/lib/types/client/locales.d.ts +1 -0
- package/lib/types/client/main-session.d.ts +32 -0
- package/lib/types/client/plugin-card-seat.d.ts +103 -0
- package/lib/types/client/settings-form.d.ts +17 -17
- package/lib/types/core/controller.d.ts +10 -8
- package/lib/types/core/session-reuse.d.ts +4 -2
- package/lib/types/host-ledger.d.ts +15 -0
- package/lib/types/http.d.ts +18 -1
- package/lib/types/index.d.ts +72 -17
- package/package.json +30 -24
- package/src/client/PluginSettingsCard.tsx +7 -2
- package/src/client/TaskBoardSettingsCard.tsx +7 -5
- package/src/client/board/NewTaskModal.tsx +33 -7
- package/src/client/board/TaskForm.tsx +13 -0
- package/src/client/board/parse-model-pref.ts +35 -0
- package/src/client/board.module.css +31 -12
- package/src/client/index.ts +121 -38
- package/src/client/locales.ts +2 -0
- package/src/client/main-session.ts +43 -0
- package/src/client/plugin-card-seat.ts +192 -0
- package/src/client/settings-form.ts +34 -31
- package/src/client/sidebar-entry-core.ts +3 -3
- package/src/client/sidebar-entry.ts +2 -2
- package/src/core/controller.ts +12 -8
- package/src/core/session-reuse.ts +14 -5
- package/src/host-ledger.ts +59 -11
- package/src/http.ts +23 -1
- package/src/index.ts +97 -52
package/src/client/locales.ts
CHANGED
|
@@ -62,6 +62,7 @@ export const zh = {
|
|
|
62
62
|
'new.prompt': '执行 Prompt',
|
|
63
63
|
'new.promptPlaceholder': '发给 agent 的完整指令(留空则使用标题)',
|
|
64
64
|
'new.submit': '创建',
|
|
65
|
+
'new.createAndRun': '创建并执行',
|
|
65
66
|
'new.cancel': '取消',
|
|
66
67
|
'new.required': '标题不能为空',
|
|
67
68
|
'new.freeze': '冻结快照(可选)',
|
|
@@ -252,6 +253,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
252
253
|
'new.prompt': 'Run Prompt',
|
|
253
254
|
'new.promptPlaceholder': 'The full instruction sent to the agent (title is used when blank)',
|
|
254
255
|
'new.submit': 'Create',
|
|
256
|
+
'new.createAndRun': 'Create and run',
|
|
255
257
|
'new.cancel': 'Cancel',
|
|
256
258
|
'new.required': 'Title is required',
|
|
257
259
|
'new.freeze': 'Frozen snapshot (optional)',
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/client/main-session.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Main-view Session derivation for the multi-instance Client Session model.
|
|
4
|
+
*
|
|
5
|
+
* The Client Session Controller carries no global "current" selection: view
|
|
6
|
+
* selection belongs to the workspace UI, which owns the main-area reference and
|
|
7
|
+
* publishes it through the catalog's per-source ownership counts
|
|
8
|
+
* (`SessionSummary.retainedBy.mainView`). A plugin that needs the Session the
|
|
9
|
+
* main view shows derives it from that marker instead of the removed
|
|
10
|
+
* `SessionListState.current` field.
|
|
11
|
+
*
|
|
12
|
+
* The marker describes view ownership, not authority. It is the right input for
|
|
13
|
+
* presentation and for deciding which view a navigation targets; it is not a
|
|
14
|
+
* business fallback, because source counts never imply visibility or success.
|
|
15
|
+
* @module dsh-web/shared/main-session
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The catalog row subset this helper reads (a `SessionSummary` projection). */
|
|
19
|
+
export interface MainViewOwnedRow<Id extends string = string> {
|
|
20
|
+
/** Catalog row identity. */
|
|
21
|
+
readonly id: Id
|
|
22
|
+
/** Per-source local ownership counts; absent rows are never owned. */
|
|
23
|
+
readonly retainedBy?: Readonly<Partial<Record<string, number>>> | undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the Session the main view currently shows.
|
|
28
|
+
*
|
|
29
|
+
* Reads the catalog's rows rather than a per-id retain-info source: ownership
|
|
30
|
+
* counts ride the list snapshot, so this neither allocates observers nor opens
|
|
31
|
+
* history, and a subscription to the list still fires when the selection moves.
|
|
32
|
+
* @param byId - the session catalog's rows (`SessionListState.byId`), when available.
|
|
33
|
+
* @returns the main-view session id, or undefined when the main view shows none.
|
|
34
|
+
*/
|
|
35
|
+
export function mainViewSessionId<Id extends string>(
|
|
36
|
+
byId: Readonly<Record<Id, MainViewOwnedRow<Id> | undefined>> | undefined | null,
|
|
37
|
+
): Id | undefined {
|
|
38
|
+
if (byId === undefined || byId === null) return undefined
|
|
39
|
+
for (const row of Object.values<MainViewOwnedRow<Id> | undefined>(byId)) {
|
|
40
|
+
if (row !== undefined && (row.retainedBy?.mainView ?? 0) > 0) return row.id
|
|
41
|
+
}
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/client/settings/plugin-card-seat.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Family plugin-card seat.
|
|
4
|
+
*
|
|
5
|
+
* A family plugin contributes its settings card to whichever plugin-card seat
|
|
6
|
+
* the running host actually renders:
|
|
7
|
+
*
|
|
8
|
+
* - `web-ui.plugin.item` — the list seat declared by the dsh-web-settings
|
|
9
|
+
* group section (this family's own first-level "Web UI plugins" section);
|
|
10
|
+
* - `plugins.bundle.config` — the official keyed seat of the harness's plugin
|
|
11
|
+
* manager page, keyed by the bundle's package name and rendered on that
|
|
12
|
+
* bundle's page. alpha.2 removed the `settings.plugin.item` keyed seat of the
|
|
13
|
+
* `ui-settings-plugins` tab that this helper used before, so a card keyed by
|
|
14
|
+
* its settings namespace has no seat to land in any more.
|
|
15
|
+
*
|
|
16
|
+
* SEAT SELECTION IS NOT A DECLARATION PROBE. The official plugin surface
|
|
17
|
+
* belongs to the harness bundle and declares its seats before any external
|
|
18
|
+
* plugin's `apply()` runs, so "is the official seat declared?" answers yes even
|
|
19
|
+
* in the deployment whose whole point is the family group. Choosing on that
|
|
20
|
+
* probe sends every family card to the official page and leaves the group's own
|
|
21
|
+
* section permanently empty — the family of reports where the section renders
|
|
22
|
+
* its heading and zero cards.
|
|
23
|
+
*
|
|
24
|
+
* The signal that actually distinguishes the two deployments is whether
|
|
25
|
+
* dsh-web-settings is loaded: it is the package that owns the group section and
|
|
26
|
+
* it publishes the `webUiSettings` service during `apply()`, which every
|
|
27
|
+
* family plugin already reads for its settings form. Group loaded -> the family
|
|
28
|
+
* seat; group absent -> the official seat.
|
|
29
|
+
*
|
|
30
|
+
* The decision is re-evaluated on every `slots/changed` because the group may
|
|
31
|
+
* apply after this plugin (the family aggregate orders it first, a profile that
|
|
32
|
+
* installs the group separately need not): the initial contribution goes to the
|
|
33
|
+
* official seat, then moves to the family seat the moment the group's section
|
|
34
|
+
* registers. The entry is disposed before the replacement is registered, so a
|
|
35
|
+
* card is never in two seats at once.
|
|
36
|
+
*
|
|
37
|
+
* The shared tree has no client-SDK dependency, so this module reads its
|
|
38
|
+
* context through the structural shape below; callers pass the plugin's own
|
|
39
|
+
* `ctx`.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** The family list seat key. */
|
|
43
|
+
export const FAMILY_PLUGIN_CARD_SEAT = 'web-ui.plugin.item'
|
|
44
|
+
|
|
45
|
+
/** The official keyed plugin-card seat key (the alpha.2 bundle-configuration seat). */
|
|
46
|
+
export const OFFICIAL_PLUGIN_CARD_SEAT = 'plugins.bundle.config'
|
|
47
|
+
|
|
48
|
+
/** The service dsh-web-settings publishes while it is loaded. */
|
|
49
|
+
export const FAMILY_GROUP_SERVICE = 'webUiSettings'
|
|
50
|
+
|
|
51
|
+
/** The slot-registry member this helper uses (structurally satisfied by ctx.slots). */
|
|
52
|
+
export interface PluginCardSlots {
|
|
53
|
+
/** Contribution of one card entry. */
|
|
54
|
+
register(options: never, component: never): unknown
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The slice of the client context a card contribution needs. */
|
|
58
|
+
export interface PluginCardContext {
|
|
59
|
+
slots: PluginCardSlots
|
|
60
|
+
/** Service lookup; absent on a context double that only models the slots. */
|
|
61
|
+
get?(name: string): unknown
|
|
62
|
+
/** Event subscription seat; absent on an event-less test double. */
|
|
63
|
+
on?(event: string, listener: (...args: never[]) => void): unknown
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Owner share of a family list-seat card (that seat supplies nothing). */
|
|
67
|
+
export interface SettingsPluginItemOwnerProps {
|
|
68
|
+
/** Marker field: card owner props are intentionally empty. */
|
|
69
|
+
children?: never
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One family plugin's card contribution. */
|
|
73
|
+
export interface PluginCardSeat {
|
|
74
|
+
/**
|
|
75
|
+
* Npm package name of the bundle this card configures; the official keyed
|
|
76
|
+
* seat dispatches on it. Every family package ships its own bundle patch, so
|
|
77
|
+
* a standalone install lists that package as the bundle whose page renders
|
|
78
|
+
* this card.
|
|
79
|
+
*/
|
|
80
|
+
bundle: string
|
|
81
|
+
/** Family list-seat entry id. */
|
|
82
|
+
id: string
|
|
83
|
+
/** Family list-seat sort order. */
|
|
84
|
+
order?: number
|
|
85
|
+
/** Family list-seat display label; the official keyed seat carries none. */
|
|
86
|
+
label?: () => string
|
|
87
|
+
/** Locale namespace the card renders with. */
|
|
88
|
+
locale: string
|
|
89
|
+
/** Business-face factory of the registration. */
|
|
90
|
+
inject?: () => object
|
|
91
|
+
/**
|
|
92
|
+
* The card component. Its props type is the seat's composed shape, which is
|
|
93
|
+
* chosen at runtime, so the helper takes it erased; the registrant keeps its
|
|
94
|
+
* own precise typing at the call site.
|
|
95
|
+
*/
|
|
96
|
+
component: unknown
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whether the family group (dsh-web-settings) is loaded in this page. The
|
|
101
|
+
* service is the group package's own contract, so the probe cannot be fooled
|
|
102
|
+
* by a harness release that starts declaring the official seat differently.
|
|
103
|
+
*/
|
|
104
|
+
export function familyGroupLoaded(ctx: PluginCardContext): boolean {
|
|
105
|
+
const get = ctx.get
|
|
106
|
+
if (typeof get !== 'function') return false
|
|
107
|
+
try {
|
|
108
|
+
return get.call(ctx, FAMILY_GROUP_SERVICE) !== undefined
|
|
109
|
+
} catch {
|
|
110
|
+
// A context that refuses the lookup: treat the group as absent, which
|
|
111
|
+
// falls back to the official seat the harness declares.
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Report a refused registration instead of leaving the user with no card. */
|
|
117
|
+
function warnRefusedSeat(seat: string, error: unknown): void {
|
|
118
|
+
try {
|
|
119
|
+
console.warn(`[dsh-web] plugin card registration into "${seat}" was refused; the card will not render`, error)
|
|
120
|
+
} catch {
|
|
121
|
+
// Best-effort console write; a failed warning must not break the plugin.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Contribute one family plugin card to the seat this host renders, following
|
|
127
|
+
* the group if it loads later. The entry is disposed and re-registered on a
|
|
128
|
+
* seat change, never duplicated.
|
|
129
|
+
* @param ctx - client context (its slot registry decides the seat).
|
|
130
|
+
* @param seat - the card contribution.
|
|
131
|
+
*/
|
|
132
|
+
export function installPluginCard(ctx: PluginCardContext, seat: PluginCardSeat): void {
|
|
133
|
+
const slots = ctx.slots
|
|
134
|
+
const component = seat.component as never
|
|
135
|
+
const inject = seat.inject as never
|
|
136
|
+
|
|
137
|
+
let dispose: (() => void) | undefined
|
|
138
|
+
let current: string | undefined
|
|
139
|
+
/**
|
|
140
|
+
* Re-entrancy latch. The registry emits a change event synchronously from
|
|
141
|
+
* inside both `register` and the previous entry's disposer, so an unguarded
|
|
142
|
+
* reconcile would re-enter itself mid-move and register the card twice into
|
|
143
|
+
* the seat it is leaving ("already has an entry for key ...").
|
|
144
|
+
*/
|
|
145
|
+
let reconciling = false
|
|
146
|
+
|
|
147
|
+
/** Reconcile the contribution with the currently live seat (no-op when unchanged). */
|
|
148
|
+
const reconcile = (): void => {
|
|
149
|
+
if (reconciling) return
|
|
150
|
+
const target = familyGroupLoaded(ctx) ? FAMILY_PLUGIN_CARD_SEAT : OFFICIAL_PLUGIN_CARD_SEAT
|
|
151
|
+
if (current === target) return
|
|
152
|
+
reconciling = true
|
|
153
|
+
const previous = dispose
|
|
154
|
+
dispose = undefined
|
|
155
|
+
current = undefined
|
|
156
|
+
previous?.()
|
|
157
|
+
try {
|
|
158
|
+
dispose = slots.register((target === FAMILY_PLUGIN_CARD_SEAT
|
|
159
|
+
? {
|
|
160
|
+
name: FAMILY_PLUGIN_CARD_SEAT,
|
|
161
|
+
id: seat.id,
|
|
162
|
+
...(seat.order === undefined ? {} : { order: seat.order }),
|
|
163
|
+
...(seat.label === undefined ? {} : { label: seat.label }),
|
|
164
|
+
locale: seat.locale,
|
|
165
|
+
...(seat.inject === undefined ? {} : { inject }),
|
|
166
|
+
}
|
|
167
|
+
: {
|
|
168
|
+
name: OFFICIAL_PLUGIN_CARD_SEAT,
|
|
169
|
+
key: seat.bundle,
|
|
170
|
+
locale: seat.locale,
|
|
171
|
+
...(seat.inject === undefined ? {} : { inject }),
|
|
172
|
+
}) as never, component) as () => void
|
|
173
|
+
current = target
|
|
174
|
+
} catch (error) {
|
|
175
|
+
warnRefusedSeat(target, error)
|
|
176
|
+
} finally {
|
|
177
|
+
reconciling = false
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (typeof ctx.on === 'function') {
|
|
182
|
+
try {
|
|
183
|
+
ctx.on('slots/changed', () => { reconcile() })
|
|
184
|
+
} catch {
|
|
185
|
+
// An event seat that refuses the subscription leaves the initial
|
|
186
|
+
// decision in place; the card still renders in the seat chosen below.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// The initial decision: the group, when it is already loaded (the aggregate
|
|
190
|
+
// order), otherwise the official seat the harness declares.
|
|
191
|
+
reconcile()
|
|
192
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* card-store pattern.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type {
|
|
11
|
+
import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
12
12
|
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
|
13
13
|
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
|
14
14
|
|
|
@@ -114,7 +114,7 @@ interface PlannedWrite {
|
|
|
114
114
|
judge: (() => boolean) | undefined
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
/** One durable write inside the save's atomic
|
|
117
|
+
/** One durable write inside the save's atomic form mutation. */
|
|
118
118
|
export interface BatchedWrite {
|
|
119
119
|
/** Field this entry writes. */
|
|
120
120
|
field: string
|
|
@@ -211,34 +211,34 @@ export class CardForm<T> {
|
|
|
211
211
|
private readonly specs: Map<string, FieldSpec>
|
|
212
212
|
private readonly staged = new Map<string, StagedEdit>()
|
|
213
213
|
private readonly listeners = new Set<() => void>()
|
|
214
|
-
/** The
|
|
215
|
-
private readonly
|
|
214
|
+
/** The form subscription installed in the constructor; released by dispose(). */
|
|
215
|
+
private readonly disposeForm: () => void
|
|
216
216
|
private disposed = false
|
|
217
217
|
private saving = false
|
|
218
218
|
private failed = false
|
|
219
219
|
private failedReason: string | undefined
|
|
220
220
|
|
|
221
|
-
/** @param scope - the bound
|
|
221
|
+
/** @param scope - the bound configuration form for this card's namespace. */
|
|
222
222
|
constructor(
|
|
223
|
-
private readonly scope:
|
|
223
|
+
private readonly scope: ConfigForm<T>,
|
|
224
224
|
specs: FieldSpec[],
|
|
225
225
|
) {
|
|
226
226
|
this.specs = new Map(specs.map(spec => [spec.field, spec]))
|
|
227
|
-
this.
|
|
227
|
+
this.disposeForm = scope.subscribe(() => { this.publish() })
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
/**
|
|
231
|
-
* Release the
|
|
231
|
+
* Release the form subscription and every bound store listener. The card
|
|
232
232
|
* must call this on teardown; later calls are no-ops.
|
|
233
233
|
*/
|
|
234
234
|
dispose(): void {
|
|
235
235
|
if (this.disposed) return
|
|
236
236
|
this.disposed = true
|
|
237
|
-
this.
|
|
237
|
+
this.disposeForm()
|
|
238
238
|
this.listeners.clear()
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
/** Publish a projection of this form, rebuilt whenever the
|
|
241
|
+
/** Publish a projection of this form, rebuilt whenever the form or a draft changes. */
|
|
242
242
|
bind<S>(project: () => S): SnapshotStore<S> {
|
|
243
243
|
const store = createSnapshotStore(project())
|
|
244
244
|
this.listeners.add(() => { store.set(project()) })
|
|
@@ -295,19 +295,19 @@ export class CardForm<T> {
|
|
|
295
295
|
}
|
|
296
296
|
|
|
297
297
|
/**
|
|
298
|
-
* Write every staged edit in one atomic
|
|
298
|
+
* Write every staged edit in one atomic form mutation, then re-seed from
|
|
299
299
|
* what the Host accepted.
|
|
300
300
|
*
|
|
301
301
|
* The whole batch rides one mutate, so cross-field validate hooks
|
|
302
302
|
* (baseURL+model) judge it as a unit: the Host either applies every write
|
|
303
|
-
* or refuses the batch. The
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
* settled snapshot back
|
|
307
|
-
* fails the whole save. A
|
|
308
|
-
*
|
|
309
|
-
* message. A save that did not land keeps its drafts, so
|
|
310
|
-
* correct them instead of retyping.
|
|
303
|
+
* or refuses the batch. The form contract answers a refusal or a skipped
|
|
304
|
+
* write with `false` (it recovers with a fresh Host view instead of
|
|
305
|
+
* throwing), so the outcome is judged twice: the answer itself, and then the
|
|
306
|
+
* settled snapshot read back one planned write at a time. One missed write
|
|
307
|
+
* fails the whole save. A transport that rejects instead (the dsh-web bridge
|
|
308
|
+
* controller on a dead connection) reports through the same failure path
|
|
309
|
+
* with its rejection message. A save that did not land keeps its drafts, so
|
|
310
|
+
* the user can correct them instead of retyping.
|
|
311
311
|
* @returns settlement after the mutation and the read-back.
|
|
312
312
|
*/
|
|
313
313
|
async save(): Promise<void> {
|
|
@@ -323,30 +323,33 @@ export class CardForm<T> {
|
|
|
323
323
|
this.failed = false
|
|
324
324
|
this.failedReason = undefined
|
|
325
325
|
this.publish()
|
|
326
|
-
// One atomic namespace mutation: the
|
|
327
|
-
//
|
|
326
|
+
// One atomic namespace mutation: the form contract takes ordered path
|
|
327
|
+
// operations, so the whole staged batch is validated, persisted, and
|
|
328
328
|
// recovered together — either every write lands or none does.
|
|
329
329
|
const ops: Array<{ op: 'set'; path: string[]; value: string | number | boolean } | { op: 'unset'; path: string[] }> = valid.map(item => item.op.op === 'set'
|
|
330
330
|
? { op: 'set', path: [item.field], value: (item.op as { value: string | number | boolean }).value }
|
|
331
331
|
: { op: 'unset', path: [item.field] })
|
|
332
332
|
let failedReason: string | undefined
|
|
333
|
+
let accepted = false
|
|
333
334
|
try {
|
|
334
|
-
await this.scope.mutate(ops)
|
|
335
|
+
accepted = await this.scope.mutate(ops)
|
|
335
336
|
} catch (error) {
|
|
336
337
|
failedReason = error instanceof Error ? error.message : String(error)
|
|
337
338
|
}
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
// judge every planned write against the settled
|
|
341
|
-
// atomic, so one missed write fails the
|
|
342
|
-
|
|
339
|
+
// `false` is the contract's refusal/skip answer, and the form still
|
|
340
|
+
// resolves after a refusal (it recovers with a fresh view), so the answer
|
|
341
|
+
// alone is not enough: judge every planned write against the settled
|
|
342
|
+
// snapshot as well. The mutation is atomic, so one missed write fails the
|
|
343
|
+
// whole save and keeps the drafts.
|
|
344
|
+
const landed = accepted && failedReason === undefined && valid.every(item => item.judge())
|
|
343
345
|
for (const [field, before] of pending) {
|
|
344
346
|
if (landed && this.staged.get(field) === before) this.staged.delete(field)
|
|
345
347
|
}
|
|
346
348
|
this.saving = false
|
|
347
349
|
this.failed = !landed
|
|
348
|
-
// A
|
|
349
|
-
// generic failure copy; a rejecting
|
|
350
|
+
// A refused or unlanded batch carries no server reason: the card surfaces
|
|
351
|
+
// its generic failure copy; a rejecting transport (the bridge) adds its
|
|
352
|
+
// message.
|
|
350
353
|
this.failedReason = failedReason
|
|
351
354
|
this.publish()
|
|
352
355
|
}
|
|
@@ -381,7 +384,7 @@ export class CardForm<T> {
|
|
|
381
384
|
*/
|
|
382
385
|
private landedSet(field: string, value: unknown): boolean {
|
|
383
386
|
// A redacted secret never appears in any wire view layer: the Host strips
|
|
384
|
-
// role('secret') fields and reports them through a sidecar the
|
|
387
|
+
// role('secret') fields and reports them through a sidecar the form
|
|
385
388
|
// snapshot does not expose, so there is nothing to compare the draft
|
|
386
389
|
// against. Settling is the only signal the form has; the rest of the
|
|
387
390
|
// batch, when one exists, still carries the atomic verdict by read-back.
|
|
@@ -412,7 +415,7 @@ export class CardForm<T> {
|
|
|
412
415
|
return spec
|
|
413
416
|
}
|
|
414
417
|
|
|
415
|
-
private snapshotOf():
|
|
418
|
+
private snapshotOf(): ConfigFormSnapshot<T> {
|
|
416
419
|
return this.scope.getSnapshot()
|
|
417
420
|
}
|
|
418
421
|
|
|
@@ -84,8 +84,8 @@ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
|
|
|
84
84
|
return undefined
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
/** Build the entry row (
|
|
88
|
-
function createEntry(options: SidebarEntryOptions): { entry:
|
|
87
|
+
/** Build the entry row (detached; inserted once the shell is up). */
|
|
88
|
+
function createEntry(options: SidebarEntryOptions): { entry: HTMLElement; applyLabel: () => void } {
|
|
89
89
|
const entry = document.createElement('button')
|
|
90
90
|
entry.type = 'button'
|
|
91
91
|
entry.setAttribute(options.rowAttribute, '')
|
|
@@ -111,7 +111,7 @@ function createEntry(options: SidebarEntryOptions): { entry: HTMLButtonElement;
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
/** Re-insert the entry after the New Session row (before the browser region). */
|
|
114
|
-
function placeEntry(root: HTMLElement, entry:
|
|
114
|
+
function placeEntry(root: HTMLElement, entry: HTMLElement, options: SidebarEntryOptions): boolean {
|
|
115
115
|
const button = newSessionButton(root)
|
|
116
116
|
if (button === undefined) return false
|
|
117
117
|
if (entry.parentElement !== root) {
|
|
@@ -19,8 +19,8 @@ import { mountSidebarEntry as mountSharedSidebarEntry } from './sidebar-entry-co
|
|
|
19
19
|
/** Stable data attribute identifying the injected entry row. */
|
|
20
20
|
export const ENTRY_SELECTOR = '[data-dsh-taskboard-entry]'
|
|
21
21
|
|
|
22
|
-
/** Inline icon normalized to the shell's
|
|
23
|
-
const ICON = '<svg viewBox="0 0 16 16" width="
|
|
22
|
+
/** Inline icon normalized to the shell's 16px navigation glyph size. */
|
|
23
|
+
const ICON = '<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2.5" width="12" height="11" rx="1.5"/><path d="M2 6.5h12M6.5 6.5v7"/></svg>'
|
|
24
24
|
|
|
25
25
|
/** Locale-change subscription the shared core asks for (ctx.locale.subscribe shape). */
|
|
26
26
|
export interface LocaleRefreshSource { subscribe(listener: () => void): () => void }
|
package/src/core/controller.ts
CHANGED
|
@@ -42,18 +42,22 @@ export interface TaskBoardTransport {
|
|
|
42
42
|
parseDraft?(request: TaskBoardParseRequest, signal?: AbortSignal): Promise<TaskBoardParseDraft>
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* The sessions face the controller needs for navigation awareness. The Client
|
|
47
|
+
* Session Controller carries no global selection since 0.1.6-alpha.2, so the
|
|
48
|
+
* wiring resolves the main-view Session once and exposes it as current().
|
|
49
|
+
*/
|
|
46
50
|
export interface SessionsControllerFace {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
/** Select a session as current (navigates the conversation view). */
|
|
51
|
+
/** The Session the main view currently shows, when any. */
|
|
52
|
+
current(): string | undefined
|
|
53
|
+
/** Navigate the main view to a Session. */
|
|
52
54
|
open(id: string): void
|
|
55
|
+
/** Subscribe to catalog changes; selection ownership rides the same snapshot. */
|
|
56
|
+
subscribe(fn: () => void): () => void
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
function currentOf(sessions: SessionsControllerFace | undefined): string | undefined {
|
|
56
|
-
return sessions?.
|
|
60
|
+
return sessions?.current()
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
/** Controller dependencies (all swappable in tests). */
|
|
@@ -194,7 +198,7 @@ export class BoardController {
|
|
|
194
198
|
this.notify()
|
|
195
199
|
}) : undefined
|
|
196
200
|
if (unsubscribeExternal !== undefined) this.disposers.push(unsubscribeExternal)
|
|
197
|
-
this.disposers.push(this.deps.sessions.
|
|
201
|
+
this.disposers.push(this.deps.sessions.subscribe(() => {
|
|
198
202
|
this.onSessionsChanged()
|
|
199
203
|
}))
|
|
200
204
|
this.notify()
|
|
@@ -3,14 +3,16 @@
|
|
|
3
3
|
* previous execution's conversation. Pure, so the fail-closed conditions stay
|
|
4
4
|
* unit-testable without a gateway.
|
|
5
5
|
*/
|
|
6
|
-
import type { TaskRecord } from './tasks.ts'
|
|
6
|
+
import type { ExecutionRecord, TaskRecord } from './tasks.ts'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Pick the session a new execution of this task may continue in, or undefined
|
|
10
10
|
* to mint a fresh conversation. Reuse requires ALL of:
|
|
11
11
|
* - the task opted in (`reuseSession === true`);
|
|
12
|
-
* - the task's newest execution carries a session id
|
|
13
|
-
*
|
|
12
|
+
* - the task's newest SETTLED execution carries a session id (issue #1587: the
|
|
13
|
+
* launch path calls this after `startExecution` appended this run's own open
|
|
14
|
+
* record, so reading the array tail always found an unsettled row and reuse
|
|
15
|
+
* never happened);
|
|
14
16
|
* - the roster is known and that session is present and idle.
|
|
15
17
|
*
|
|
16
18
|
* An unknown roster (session/list unavailable) never reuses: minting a fresh
|
|
@@ -25,7 +27,14 @@ export function reusableSessionId(
|
|
|
25
27
|
idleSessionIds: ReadonlySet<string> | undefined,
|
|
26
28
|
): string | undefined {
|
|
27
29
|
if (task.reuseSession !== true || idleSessionIds === undefined) return undefined
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
let last: ExecutionRecord | undefined
|
|
31
|
+
for (let index = task.executions.length - 1; index >= 0; index -= 1) {
|
|
32
|
+
const candidate = task.executions[index]
|
|
33
|
+
if (candidate.sessionId !== undefined && candidate.endedAt !== undefined) {
|
|
34
|
+
last = candidate
|
|
35
|
+
break
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (last === undefined || last.sessionId === undefined) return undefined
|
|
30
39
|
return idleSessionIds.has(last.sessionId) ? last.sessionId : undefined
|
|
31
40
|
}
|
package/src/host-ledger.ts
CHANGED
|
@@ -134,6 +134,11 @@ export function processIsAlive(pid: number): boolean {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
const PROCESS_PROBE_TIMEOUT_MS = 3000
|
|
137
|
+
/**
|
|
138
|
+
* The CIM fallback pays a WMI cold start, so it gets a wider budget than the
|
|
139
|
+
* direct `Get-Process` read it backs up.
|
|
140
|
+
*/
|
|
141
|
+
const CIM_PROBE_TIMEOUT_MS = 8000
|
|
137
142
|
|
|
138
143
|
let ownStartTime: number | undefined
|
|
139
144
|
let ownStartTimeResolved = false
|
|
@@ -161,6 +166,59 @@ function linuxStartTimeMs(pid: number): number | undefined {
|
|
|
161
166
|
}
|
|
162
167
|
}
|
|
163
168
|
|
|
169
|
+
/** Runs one PowerShell script and returns its trimmed stdout. */
|
|
170
|
+
export type PowerShellProbe = (script: string, timeoutMs: number) => string | undefined
|
|
171
|
+
|
|
172
|
+
/** Default probe: one hidden, profile-free PowerShell process per script. */
|
|
173
|
+
const runPowerShellProbe: PowerShellProbe = (script, timeoutMs) => {
|
|
174
|
+
const probe = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
175
|
+
timeout: timeoutMs,
|
|
176
|
+
windowsHide: true,
|
|
177
|
+
})
|
|
178
|
+
if (probe.status !== 0 || probe.stdout.length === 0) return undefined
|
|
179
|
+
return probe.stdout.toString('utf8').trim()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The epoch-millisecond reading a probe printed, or undefined when unusable. */
|
|
183
|
+
function parseProbeEpochMs(raw: string | undefined): number | undefined {
|
|
184
|
+
if (raw === undefined || raw.trim() === '') return undefined
|
|
185
|
+
const started = Number(raw.trim())
|
|
186
|
+
return Number.isFinite(started) ? started : undefined
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Start time through Get-Process: precise, but empty for protected processes. */
|
|
190
|
+
function getProcessStartScript(pid: number): string {
|
|
191
|
+
return '[DateTimeOffset]::FromFileTime((Get-Process -Id ' + String(pid)
|
|
192
|
+
+ ' -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()'
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Start time through Win32_Process: readable for System/svchost too. */
|
|
196
|
+
function cimStartScript(pid: number): string {
|
|
197
|
+
return '$p=Get-CimInstance Win32_Process -Filter "ProcessId=' + String(pid)
|
|
198
|
+
+ '" -ErrorAction SilentlyContinue;if($p -ne $null){[DateTimeOffset]::FromFileTime($p.CreationDate.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()}'
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Windows start time (Unix epoch ms) of a live process.
|
|
203
|
+
*
|
|
204
|
+
* `Get-Process` is the precise first choice, but an unprivileged caller cannot
|
|
205
|
+
* read `.StartTime` for a protected process (System, svchost): the property is
|
|
206
|
+
* empty, so the probe returns nothing. Without a fallback, a crash leftover
|
|
207
|
+
* lock whose PID was reused by such a process could never be proven stale and
|
|
208
|
+
* blocked every startup until the lock was deleted by hand (issue #1629).
|
|
209
|
+
* Win32_Process through CIM reports the same CreationDate for those processes
|
|
210
|
+
* at the same millisecond precision, so it is the second identity source. The
|
|
211
|
+
* probe is injectable so the fallback chain is testable off Windows.
|
|
212
|
+
*/
|
|
213
|
+
export function win32StartTimeMs(pid: number, probe: PowerShellProbe = runPowerShellProbe): number | undefined {
|
|
214
|
+
// The pid is interpolated into a PowerShell script, so it must be a plain
|
|
215
|
+
// positive integer before any probe runs.
|
|
216
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return undefined
|
|
217
|
+
const direct = parseProbeEpochMs(probe(getProcessStartScript(pid), PROCESS_PROBE_TIMEOUT_MS))
|
|
218
|
+
if (direct !== undefined) return direct
|
|
219
|
+
return parseProbeEpochMs(probe(cimStartScript(pid), CIM_PROBE_TIMEOUT_MS))
|
|
220
|
+
}
|
|
221
|
+
|
|
164
222
|
/**
|
|
165
223
|
* Best-effort start time (Unix epoch ms) of a live process. Used to prove
|
|
166
224
|
* whether the ledger lock really belongs to the PID recorded in it, so a
|
|
@@ -170,17 +228,7 @@ function linuxStartTimeMs(pid: number): number | undefined {
|
|
|
170
228
|
*/
|
|
171
229
|
function processStartTimeMs(pid: number): number | undefined {
|
|
172
230
|
if (process.platform === 'linux') return linuxStartTimeMs(pid)
|
|
173
|
-
if (process.platform === 'win32')
|
|
174
|
-
const probe = spawnSync(
|
|
175
|
-
'powershell',
|
|
176
|
-
['-NoProfile', '-NonInteractive', '-Command',
|
|
177
|
-
'[DateTimeOffset]::FromFileTime((Get-Process -Id ' + String(pid) + ' -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()'],
|
|
178
|
-
{ timeout: PROCESS_PROBE_TIMEOUT_MS, windowsHide: true },
|
|
179
|
-
)
|
|
180
|
-
if (probe.status !== 0 || probe.stdout.length === 0) return undefined
|
|
181
|
-
const started = Number(probe.stdout.toString('utf8').trim())
|
|
182
|
-
return Number.isFinite(started) ? started : undefined
|
|
183
|
-
}
|
|
231
|
+
if (process.platform === 'win32') return win32StartTimeMs(pid)
|
|
184
232
|
// Other POSIX (macOS...): ps lstart with a forced English locale, falling
|
|
185
233
|
// back to the elapsed-seconds column when lstart cannot be parsed.
|
|
186
234
|
const env = { ...process.env, LC_ALL: 'C' }
|
package/src/http.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Shared JSON body/response helpers for the host route families: one strict
|
|
4
4
|
* bounded body reader, one lenient bounded body reader, one JSON object
|
|
5
|
-
* narrow,
|
|
5
|
+
* narrow, one JSON writer, and the outbound request-init helper that keeps a
|
|
6
|
+
* host fetch body decodable (withIdentityEncoding). Previously the readers
|
|
7
|
+
* were copy-pasted across the
|
|
6
8
|
* package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
|
|
7
9
|
* family's route module) with drifting contracts: body caps ranging 4 KiB to
|
|
8
10
|
* 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
|
|
@@ -88,6 +90,26 @@ export function asJsonObject(value: unknown): Record<string, unknown> | undefine
|
|
|
88
90
|
return isJsonObject(value) ? value : undefined
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Request init that asks a remote origin for an uncompressed body.
|
|
95
|
+
*
|
|
96
|
+
* The DSH host boots `@deepseek-ai/dsh-http-proxy`, whose top-level import of
|
|
97
|
+
* the npm `undici` copy replaces the legacy `undici.globalDispatcher.1` slot
|
|
98
|
+
* Node's built-in `fetch()` reads. That cross-major wrapper drops the
|
|
99
|
+
* `content-encoding` header and automatic decompression, so a plain fetch
|
|
100
|
+
* resolves to raw gzip/brotli/zstd bytes — compressed noise no JSON or text
|
|
101
|
+
* consumer can read. Every host fetch that parses a remote body must request
|
|
102
|
+
* identity encoding. Caller headers survive; `accept-encoding` is forced to
|
|
103
|
+
* `identity` because a caller value has no decoder behind it in this host.
|
|
104
|
+
* @param init - the caller's request init (signal, headers, method, ...).
|
|
105
|
+
* @returns a copy with `accept-encoding: identity` merged in.
|
|
106
|
+
*/
|
|
107
|
+
export function withIdentityEncoding(init: RequestInit = {}): RequestInit {
|
|
108
|
+
const headers = new Headers(init.headers)
|
|
109
|
+
headers.set('accept-encoding', 'identity')
|
|
110
|
+
return { ...init, headers }
|
|
111
|
+
}
|
|
112
|
+
|
|
91
113
|
/**
|
|
92
114
|
* Write one JSON response. Default headers are the family defaults
|
|
93
115
|
* (content-type and referrer-policy); caller headers are appended or
|