@linxin666/dsh-client-ui-task-board 0.3.19 → 0.3.21

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/lib/index.js CHANGED
@@ -154,7 +154,7 @@ function parseField(field, min, max, out) {
154
154
  high = Number(b);
155
155
  } else if (isDigits(range)) {
156
156
  low = Number(range);
157
- high = Number(range);
157
+ high = stepRaw === void 0 ? low : max;
158
158
  } else return false;
159
159
  if (low < min || high > max || low > high) return false;
160
160
  const step = stepRaw === void 0 ? 1 : isDigits(stepRaw) ? Number(stepRaw) : NaN;
@@ -244,8 +244,18 @@ function retainRecentExecutions(executions) {
244
244
  const keepSettled = Math.max(20 - open.length, 0);
245
245
  return [...settled.slice(Math.max(settled.length - keepSettled, 0)), ...open];
246
246
  }
247
- /** Statuses a settled task may be archived from. */
248
- const ARCHIVABLE_STATUSES = ["done", "failed"];
247
+ /**
248
+ * Statuses a task may be archived from: every status but `running`, whose
249
+ * execution the runner still owns until it settles. A settled-only gate made
250
+ * the duplicate-and-archive flow a silent no-op for scheduled tasks, which
251
+ * return to `todo` after every successful run (issue #1447).
252
+ */
253
+ const ARCHIVABLE_STATUSES = [
254
+ "backlog",
255
+ "todo",
256
+ "done",
257
+ "failed"
258
+ ];
249
259
  /** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */
250
260
  const TASK_PERMISSIONS = [
251
261
  "read-only",
@@ -731,10 +741,10 @@ function parseLedger(raw) {
731
741
  //#endregion
732
742
  //#region src/core/use-cases/task-archive.ts
733
743
  /**
734
- * Archive one task: only settled statuses (done/failed) can be archived;
735
- * a running or not-yet-settled task stays on the board (its runner still
736
- * owns its lifecycle). Archiving disarms a schedule; already-archived tasks
737
- * are a no-op.
744
+ * Archive one task: only a `running` task stays on the board (its runner
745
+ * still owns its lifecycle until the execution settles); every other status
746
+ * can be archived. Archiving disarms a schedule; already-archived tasks are
747
+ * a no-op.
738
748
  */
739
749
  function applyArchiveTask(tasks, id, now) {
740
750
  let applied = false;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * One `document.body` MutationObserver per page, shared by every family
3
+ * plugin bundle.
4
+ *
5
+ * Family plugins that inject at the DOM level must notice when the shell
6
+ * re-renders around their injected node: the sidebar entry rows
7
+ * (sidebar-entry-core), the center-column panel containers
8
+ * (panel-mount-core), and the aggregate shell's column shims. Each consumer
9
+ * used to install its OWN `MutationObserver` on `document.body` with
10
+ * `{ childList: true, subtree: true }`, so a page carrying N family plugins
11
+ * paid N native observers and N callback invocations for EVERY mutation batch
12
+ * the app produced — chat token streaming alone produces many per second, and
13
+ * the count grows with the number of installed plugins.
14
+ *
15
+ * This module keeps exactly ONE body observer per page. The registry lives on
16
+ * `globalThis` under a `Symbol.for` key, so the per-package generated copies
17
+ * of this file (scripts/sync-shared.mjs) and separately bundled plugins all
18
+ * reach the same hub at runtime instead of one hub per module instance.
19
+ * Record subscribers receive the mutations accumulated since the last flush.
20
+ * Consumers that only re-check their DOM use subscribeBodyInvalidations:
21
+ * when all subscribers use that path, the hub retains no mutation records
22
+ * (or detached subtrees) while a background page's animation frames pause.
23
+ * Both paths run at most once per frame. The last subscriber disconnects the
24
+ * observer, cancels the pending frame and releases its records.
25
+ *
26
+ * Failure policy: without a DOM or `MutationObserver` the subscription is a
27
+ * no-op disposer (the same silence the per-consumer observers had), and when
28
+ * `requestAnimationFrame` is unavailable the flush runs synchronously so a
29
+ * subscriber is never silently dropped. A throwing subscriber cannot stop the
30
+ * others.
31
+ */
32
+ /**
33
+ * Subscribe to a coalesced DOM re-check without retaining mutation records.
34
+ * The marked wrapper also works with an older hub, which delivers records
35
+ * that it simply ignores until a page reload picks up the updated hub.
36
+ */
37
+ export declare function subscribeBodyInvalidations(subscriber: () => void): () => void;
38
+ /**
39
+ * Subscribe to body-level childList mutations.
40
+ * @param subscriber - called at most once per animation frame with the records
41
+ * collected since the previous flush; must be safe to run repeatedly.
42
+ * @returns the disposer removing this subscriber (and the observer when it was
43
+ * the last one).
44
+ */
45
+ export declare function subscribeBodyMutations(subscriber: (records: MutationRecord[]) => void): () => void;
@@ -21,7 +21,7 @@
21
21
  import { type Root } from 'react-dom/client';
22
22
  /** Options for mountCenterPanel; dsh-ssh mount.tsx and dsh-task-board board-mount.tsx are the canonical consumers. */
23
23
  export interface CenterPanelMountOptions {
24
- /** Render the panel React tree into a root (initial mount, remount, locale refresh). */
24
+ /** Render the panel tree (first open, remount while open, locale refresh). */
25
25
  render: (root: Root) => void;
26
26
  /** dataset key of the injected container's view attribute, e.g. `dshSshView` for `data-dsh-ssh-view`. */
27
27
  viewDatasetKey: string;
@@ -1,18 +1,3 @@
1
- /**
2
- * Shared sidebar entry injection core.
3
- *
4
- * dsh's sidebar shell exposes no slot an external plugin can register into,
5
- * so the entry row is injected between the shell's New Session button and the
6
- * workspace browser. The injection self-heals: a MutationObserver watches the
7
- * sidebar root and re-inserts the row whenever a React re-render displaces it
8
- * (re-insertion happens in the same frame, before paint, so no flicker).
9
- *
10
- * The row is plain DOM (no React tree) so it can never disturb the shell's
11
- * reconciliation; the view it toggles is a separate root owned by the caller.
12
- *
13
- * Packages receive this file as a generated copy via scripts/sync-shared.mjs;
14
- * edit the shared source and re-run the sync instead of editing a copy.
15
- */
16
1
  /** Per-package configuration for one sidebar entry row. */
17
2
  export interface SidebarEntryOptions {
18
3
  /** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
@@ -154,9 +154,8 @@ export declare class BoardController {
154
154
  moveTask(id: string, status: TaskStatus): void;
155
155
  deleteTask(id: string): void;
156
156
  /**
157
- * Archive a settled task (done/failed). Running or on-board-unsettled
158
- * tasks are refused so the runner keeps exclusive ownership of their
159
- * lifecycle.
157
+ * Archive a task from any status but `running`, whose lifecycle the runner
158
+ * keeps exclusive ownership of until it settles.
160
159
  * @returns true when applied.
161
160
  */
162
161
  archiveTask(id: string): boolean;
@@ -158,7 +158,12 @@ export interface TaskRecord {
158
158
  */
159
159
  archivedAt?: number;
160
160
  }
161
- /** Statuses a settled task may be archived from. */
161
+ /**
162
+ * Statuses a task may be archived from: every status but `running`, whose
163
+ * execution the runner still owns until it settles. A settled-only gate made
164
+ * the duplicate-and-archive flow a silent no-op for scheduled tasks, which
165
+ * return to `todo` after every successful run (issue #1447).
166
+ */
162
167
  export declare const ARCHIVABLE_STATUSES: readonly TaskStatus[];
163
168
  /** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */
164
169
  export declare const TASK_PERMISSIONS: readonly ["read-only", "workspace-write", "danger-full-access"];
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Archive/restore task use case: move a settled (done/failed) task off the
3
- * main board and back. The task keeps its status, execution history, and
2
+ * Archive/restore task use case: move a task off the main board and back,
3
+ * whatever its status but `running`. The task keeps its status, execution history, and
4
4
  * transcript references, while archiving disarms any schedule so it cannot
5
5
  * create more execution records until the user restores and re-enables it.
6
6
  */
@@ -13,10 +13,10 @@ export interface ArchiveTaskResult {
13
13
  archived: boolean;
14
14
  }
15
15
  /**
16
- * Archive one task: only settled statuses (done/failed) can be archived;
17
- * a running or not-yet-settled task stays on the board (its runner still
18
- * owns its lifecycle). Archiving disarms a schedule; already-archived tasks
19
- * are a no-op.
16
+ * Archive one task: only a `running` task stays on the board (its runner
17
+ * still owns its lifecycle until the execution settles); every other status
18
+ * can be archived. Archiving disarms a schedule; already-archived tasks are
19
+ * a no-op.
20
20
  */
21
21
  export declare function applyArchiveTask(tasks: readonly TaskRecord[], id: string, now: number): ArchiveTaskResult;
22
22
  /** Restore one task back onto the main board (clears the archive marker). */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-client-ui-task-board",
3
3
  "description": "Host-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.",
4
- "version": "0.3.19",
4
+ "version": "0.3.21",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "dsh": {
25
25
  "engines": {
26
- "dsh": ">=0.1.2-rc.1"
26
+ "dsh": ">=0.1.5-rc.1"
27
27
  },
28
28
  "bundle": {
29
29
  "patch": "./cordis.patch.yml"
@@ -48,23 +48,23 @@
48
48
  },
49
49
  "devDependencies": {
50
50
  "@deepseek-ai/cordis": "^4.0.2",
51
- "@deepseek-ai/dsh-agent": "^0.1.2-rc.1",
52
- "@deepseek-ai/dsh-agent-presets": "^0.1.2-rc.1",
53
- "@deepseek-ai/dsh-api-gateway": "^0.1.2-rc.1",
54
- "@deepseek-ai/dsh-api-remotes": "^0.1.2-rc.1",
55
- "@deepseek-ai/dsh-workspace": "^0.1.2-rc.1",
56
- "@deepseek-ai/dsh-api-session-controller": "^0.1.2-rc.1",
57
- "@deepseek-ai/dsh-api-workspace-controller": "^0.1.2-rc.1",
58
- "@deepseek-ai/dsh-client-connection": "^0.1.2-rc.1",
59
- "@deepseek-ai/dsh-client-locale": "^0.1.2-rc.1",
60
- "@deepseek-ai/dsh-client-store": "^0.1.2-rc.1",
61
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-rc.1",
62
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-rc.1",
63
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-rc.1",
64
- "@deepseek-ai/dsh-commands": "^0.1.2-rc.1",
65
- "@deepseek-ai/dsh-host-webserver": "^0.1.2-rc.1",
66
- "@deepseek-ai/dsh-settings": "^0.1.2-rc.1",
67
- "@deepseek-ai/dsh-system-prompt": "^0.1.2-rc.1",
51
+ "@deepseek-ai/dsh-agent": "^0.1.5-rc.1",
52
+ "@deepseek-ai/dsh-agent-presets": "^0.1.5-rc.1",
53
+ "@deepseek-ai/dsh-api-gateway": "^0.1.5-rc.1",
54
+ "@deepseek-ai/dsh-api-remotes": "^0.1.5-rc.1",
55
+ "@deepseek-ai/dsh-workspace": "^0.1.5-rc.1",
56
+ "@deepseek-ai/dsh-api-session-controller": "^0.1.5-rc.1",
57
+ "@deepseek-ai/dsh-api-workspace-controller": "^0.1.5-rc.1",
58
+ "@deepseek-ai/dsh-client-connection": "^0.1.5-rc.1",
59
+ "@deepseek-ai/dsh-client-locale": "^0.1.5-rc.1",
60
+ "@deepseek-ai/dsh-client-store": "^0.1.5-rc.1",
61
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.1",
62
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.1",
63
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
64
+ "@deepseek-ai/dsh-commands": "^0.1.5-rc.1",
65
+ "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.1",
66
+ "@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
67
+ "@deepseek-ai/dsh-system-prompt": "^0.1.5-rc.1",
68
68
  "@types/node": "^22.20.0",
69
69
  "@types/react": "~18.3.1",
70
70
  "@types/react-dom": "^18.3.5",
@@ -347,8 +347,10 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
347
347
  </p>
348
348
  <p className={css.detailText}><strong>{t('detail.handover.references')}</strong></p>
349
349
  <ul className={css.executionList}>
350
- {current.handover.references.map(reference => (
351
- <li key={reference} className={css.executionRow}><code>{reference}</code></li>
350
+ {current.handover.references.map((reference, index) => (
351
+ // References are free text from the freeze block, so the same
352
+ // string can appear twice; the index keeps the key unique (#1492).
353
+ <li key={`${reference}-${index}`} className={css.executionRow}><code>{reference}</code></li>
352
354
  ))}
353
355
  </ul>
354
356
  <p className={css.detailMeta}>{t('detail.handover.bundledAt', { time: formatHostTimestamp(current.handover.bundledAt, timeZone) })}</p>
@@ -0,0 +1,133 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/body-mutations.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * One `document.body` MutationObserver per page, shared by every family
4
+ * plugin bundle.
5
+ *
6
+ * Family plugins that inject at the DOM level must notice when the shell
7
+ * re-renders around their injected node: the sidebar entry rows
8
+ * (sidebar-entry-core), the center-column panel containers
9
+ * (panel-mount-core), and the aggregate shell's column shims. Each consumer
10
+ * used to install its OWN `MutationObserver` on `document.body` with
11
+ * `{ childList: true, subtree: true }`, so a page carrying N family plugins
12
+ * paid N native observers and N callback invocations for EVERY mutation batch
13
+ * the app produced — chat token streaming alone produces many per second, and
14
+ * the count grows with the number of installed plugins.
15
+ *
16
+ * This module keeps exactly ONE body observer per page. The registry lives on
17
+ * `globalThis` under a `Symbol.for` key, so the per-package generated copies
18
+ * of this file (scripts/sync-shared.mjs) and separately bundled plugins all
19
+ * reach the same hub at runtime instead of one hub per module instance.
20
+ * Record subscribers receive the mutations accumulated since the last flush.
21
+ * Consumers that only re-check their DOM use subscribeBodyInvalidations:
22
+ * when all subscribers use that path, the hub retains no mutation records
23
+ * (or detached subtrees) while a background page's animation frames pause.
24
+ * Both paths run at most once per frame. The last subscriber disconnects the
25
+ * observer, cancels the pending frame and releases its records.
26
+ *
27
+ * Failure policy: without a DOM or `MutationObserver` the subscription is a
28
+ * no-op disposer (the same silence the per-consumer observers had), and when
29
+ * `requestAnimationFrame` is unavailable the flush runs synchronously so a
30
+ * subscriber is never silently dropped. A throwing subscriber cannot stop the
31
+ * others.
32
+ */
33
+
34
+ /** The page-wide hub: one observer plus its subscribers and pending records. */
35
+ interface BodyMutationHub {
36
+ observer: MutationObserver
37
+ subscribers: Set<(records: MutationRecord[]) => void>
38
+ pending: MutationRecord[]
39
+ scheduled: boolean
40
+ frame?: number
41
+ }
42
+
43
+ /** Cross-bundle registry key; `Symbol.for` so every module copy agrees. */
44
+ const HUB_KEY = Symbol.for('dsh-web.body-mutation-hub')
45
+ const INVALIDATION_ONLY = Symbol.for('dsh-web.body-mutation-invalidation')
46
+ type Subscriber = ((records: MutationRecord[]) => void) & { [INVALIDATION_ONLY]?: true }
47
+
48
+ function needsRecords(subscribers: Set<(records: MutationRecord[]) => void>): boolean {
49
+ for (const listener of subscribers) {
50
+ if (!(listener as Subscriber)[INVALIDATION_ONLY]) return true
51
+ }
52
+ return false
53
+ }
54
+
55
+ /**
56
+ * Subscribe to a coalesced DOM re-check without retaining mutation records.
57
+ * The marked wrapper also works with an older hub, which delivers records
58
+ * that it simply ignores until a page reload picks up the updated hub.
59
+ */
60
+ export function subscribeBodyInvalidations(subscriber: () => void): () => void {
61
+ const listener: Subscriber = () => { subscriber() }
62
+ listener[INVALIDATION_ONLY] = true
63
+ return subscribeBodyMutations(listener)
64
+ }
65
+
66
+ /**
67
+ * Subscribe to body-level childList mutations.
68
+ * @param subscriber - called at most once per animation frame with the records
69
+ * collected since the previous flush; must be safe to run repeatedly.
70
+ * @returns the disposer removing this subscriber (and the observer when it was
71
+ * the last one).
72
+ */
73
+ export function subscribeBodyMutations(subscriber: (records: MutationRecord[]) => void): () => void {
74
+ if (typeof globalThis === 'undefined' || typeof document === 'undefined') return () => {}
75
+ if (typeof MutationObserver !== 'function') return () => {}
76
+ const registry = globalThis as unknown as Record<symbol, unknown>
77
+ let hub = registry[HUB_KEY] as BodyMutationHub | undefined
78
+ if (hub === undefined) {
79
+ const subscribers = new Set<(records: MutationRecord[]) => void>()
80
+ const created: BodyMutationHub = {
81
+ observer: undefined as unknown as MutationObserver,
82
+ subscribers,
83
+ pending: [],
84
+ scheduled: false,
85
+ }
86
+ const flush = (): void => {
87
+ created.frame = undefined
88
+ created.scheduled = false
89
+ const batch = created.pending
90
+ created.pending = []
91
+ for (const listener of [...subscribers]) {
92
+ if (!subscribers.has(listener)) continue
93
+ try {
94
+ listener(batch)
95
+ } catch {
96
+ // One subscriber must never break the others; the next flush retries it.
97
+ }
98
+ }
99
+ }
100
+ const schedule = (): void => {
101
+ if (created.scheduled) return
102
+ created.scheduled = true
103
+ if (typeof requestAnimationFrame === 'function') created.frame = requestAnimationFrame(flush)
104
+ else flush()
105
+ }
106
+ created.observer = new MutationObserver((records) => {
107
+ if (needsRecords(subscribers)) {
108
+ for (const record of records) created.pending.push(record)
109
+ }
110
+ schedule()
111
+ })
112
+ created.observer.observe(document.body ?? document.documentElement, { childList: true, subtree: true })
113
+ registry[HUB_KEY] = created
114
+ hub = created
115
+ }
116
+ const active = hub
117
+ active.subscribers.add(subscriber)
118
+ let subscribed = true
119
+ return () => {
120
+ if (!subscribed) return
121
+ subscribed = false
122
+ active.subscribers.delete(subscriber)
123
+ if (!needsRecords(active.subscribers)) active.pending = []
124
+ if (active.subscribers.size === 0 && registry[HUB_KEY] === active) {
125
+ active.observer.disconnect()
126
+ if (active.frame !== undefined && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(active.frame)
127
+ active.frame = undefined
128
+ active.pending = []
129
+ active.scheduled = false
130
+ delete registry[HUB_KEY]
131
+ }
132
+ }
133
+ }
@@ -80,7 +80,7 @@ declare module '@deepseek-ai/cordis' {
80
80
  * on hosts below that cohort, which serve the same roster through the
81
81
  * connection RPC face.
82
82
  */
83
- export const inject = ['slots', 'sessions', 'workspaces', 'connection', 'settingsScope', 'locale', 'remote']
83
+ export const inject = ['slots', 'sessions', 'workspaces', 'connection', 'settingsScope', 'locale', 'remote', 'remote.session']
84
84
 
85
85
  /** One agent-preset row the mode picker consumes (either face's wire shape). */
86
86
  interface PresetRosterRow {
@@ -251,31 +251,63 @@ export function apply(ctx: ClientContext): void {
251
251
  }
252
252
  const pushModelOptions = async (): Promise<void> => {
253
253
  try {
254
- const conn = ctx.get('connection') as { api?: { llm?: { discoverModels?: () => Promise<unknown> }; sessions?: { modelCatalog?: () => Promise<unknown> } } } | undefined
255
- if (conn?.api) {
256
- let models: Array<{ id: string; name?: string; provider?: string }> = []
257
- if (typeof conn.api.llm?.discoverModels === 'function') {
258
- const res = await conn.api.llm.discoverModels() as { result?: { value?: { models?: Array<{ id: string; name?: string }> } } }
259
- const list = res?.result?.value?.models
260
- if (Array.isArray(list)) {
261
- models = list.map(m => ({ id: m.id, name: m.name }))
254
+ let models: Array<{ id: string; name?: string; provider?: string }> = []
255
+ let sessionRemote: ClientRemote['session'] | undefined
256
+ try {
257
+ sessionRemote = (remote as Partial<ClientRemote>).session
258
+ } catch {
259
+ sessionRemote = undefined
260
+ }
261
+ if (typeof sessionRemote?.modelCatalog === 'function') {
262
+ const res = await sessionRemote.modelCatalog()
263
+ if (res.ok && Array.isArray(res.value?.groups)) {
264
+ for (const g of res.value.groups) {
265
+ const provider = (g as { id?: string; provider?: string }).id ?? (g as { id?: string; provider?: string }).provider
266
+ for (const m of g.models ?? []) {
267
+ const qualifiedId = provider ? `${provider}/${m.id}` : m.id
268
+ models.push({ id: qualifiedId, name: m.name ?? m.id, provider })
269
+ }
262
270
  }
263
271
  }
264
- if (models.length === 0 && typeof conn.api.sessions?.modelCatalog === 'function') {
265
- const res = await conn.api.sessions.modelCatalog() as { result?: { value?: { groups?: Array<{ provider?: string; models?: Array<{ id: string; name?: string }> }> } } }
266
- const groups = res?.result?.value?.groups
267
- if (Array.isArray(groups)) {
268
- for (const g of groups) {
269
- for (const m of g.models ?? []) {
270
- const qualifiedId = g.provider ? `${g.provider}/${m.id}` : m.id
271
- models.push({ id: qualifiedId, name: m.name ?? m.id, provider: g.provider })
272
+ }
273
+ if (models.length === 0) {
274
+ const conn = ctx.get('connection') as {
275
+ api?: {
276
+ llm?: { discoverModels?: () => Promise<unknown> }
277
+ sessions?: { modelCatalog?: () => Promise<unknown> }
278
+ session?: { modelCatalog?: () => Promise<unknown> }
279
+ }
280
+ } | undefined
281
+ if (conn?.api) {
282
+ if (typeof conn.api.llm?.discoverModels === 'function') {
283
+ const res = await conn.api.llm.discoverModels() as { result?: { value?: { models?: Array<{ id: string; name?: string }> } } }
284
+ const list = res?.result?.value?.models
285
+ if (Array.isArray(list)) {
286
+ models = list.map(m => ({ id: m.id, name: m.name }))
287
+ }
288
+ }
289
+ const catalogFn = typeof conn.api.session?.modelCatalog === 'function'
290
+ ? conn.api.session.modelCatalog
291
+ : typeof conn.api.sessions?.modelCatalog === 'function'
292
+ ? conn.api.sessions.modelCatalog
293
+ : undefined
294
+ if (models.length === 0 && catalogFn !== undefined) {
295
+ const res = await catalogFn() as { result?: { value?: { groups?: Array<{ id?: string; provider?: string; models?: Array<{ id: string; name?: string }> }> } } }
296
+ const groups = res?.result?.value?.groups
297
+ if (Array.isArray(groups)) {
298
+ for (const g of groups) {
299
+ const provider = g.id ?? g.provider
300
+ for (const m of g.models ?? []) {
301
+ const qualifiedId = provider ? `${provider}/${m.id}` : m.id
302
+ models.push({ id: qualifiedId, name: m.name ?? m.id, provider })
303
+ }
272
304
  }
273
305
  }
274
306
  }
275
307
  }
276
- if (models.length > 0) {
277
- controller.setExecutionOptions({ models })
278
- }
308
+ }
309
+ if (models.length > 0) {
310
+ controller.setExecutionOptions({ models })
279
311
  }
280
312
  } catch (error) {
281
313
  console.error('[dsh-task-board] model options read failed', error)
@@ -20,10 +20,11 @@
20
20
  * (shared/client/sidebar-entry-core.ts, synced copy).
21
21
  */
22
22
  import { createRoot, type Root } from 'react-dom/client'
23
+ import { subscribeBodyInvalidations } from './body-mutations.ts'
23
24
 
24
25
  /** Options for mountCenterPanel; dsh-ssh mount.tsx and dsh-task-board board-mount.tsx are the canonical consumers. */
25
26
  export interface CenterPanelMountOptions {
26
- /** Render the panel React tree into a root (initial mount, remount, locale refresh). */
27
+ /** Render the panel tree (first open, remount while open, locale refresh). */
27
28
  render: (root: Root) => void
28
29
  /** dataset key of the injected container's view attribute, e.g. `dshSshView` for `data-dsh-ssh-view`. */
29
30
  viewDatasetKey: string
@@ -79,31 +80,38 @@ export function mountCenterPanel(options: CenterPanelMountOptions): () => void {
79
80
  } catch { /* locale service absent: the panel follows its next natural re-render */ }
80
81
 
81
82
  const ensure = (): void => {
82
- if (container !== undefined) {
83
- if (container.isConnected) return
83
+ if (container !== undefined && !container.isConnected) {
84
84
  // The conversation pane was replaced; drop the stale tree and remount.
85
85
  root?.unmount()
86
86
  root = undefined
87
87
  container.remove()
88
88
  container = undefined
89
89
  }
90
- const column = conversationColumn()
91
- if (column === undefined) return
92
- container = document.createElement('div')
93
- container.dataset[options.viewDatasetKey] = ''
94
- container.dataset.dshPlugin = options.pluginName
95
- container.className = options.viewClassName
96
- column.appendChild(container)
90
+ if (container === undefined) {
91
+ const column = conversationColumn()
92
+ if (column === undefined) return
93
+ container = document.createElement('div')
94
+ container.dataset[options.viewDatasetKey] = ''
95
+ container.dataset.dshPlugin = options.pluginName
96
+ container.className = options.viewClassName
97
+ column.appendChild(container)
98
+ }
99
+ // Keep a visited tree mounted so drafts and terminal sessions survive
100
+ // close/reopen; an unused panel needs neither a React root nor effects.
101
+ if (root !== undefined || !options.isOpen()) return
97
102
  root = createRoot(container)
98
103
  options.render(root)
99
104
  }
100
105
 
101
106
  // The frame mounts after boot settlement; watch for the column's arrival.
102
- const waitObserver = new MutationObserver(() => { ensure() })
103
- waitObserver.observe(document.body, { childList: true, subtree: true })
107
+ // The observation is the page-wide hub (shared/client/body-mutations.ts):
108
+ // every family panel used to hold its own document.body subtree observer, so
109
+ // the per-mutation cost grew with the number of installed plugins.
110
+ const unsubscribeBody = subscribeBodyInvalidations(() => { ensure() })
104
111
 
105
112
  const applyActive = (): void => {
106
113
  if (options.isOpen()) {
114
+ ensure()
107
115
  // Single-occupant center column: opening this panel must evict the
108
116
  // sibling panel, both its html attribute and its controller state,
109
117
  // otherwise the two panels' visibility rules fight and the second
@@ -135,7 +143,7 @@ export function mountCenterPanel(options: CenterPanelMountOptions): () => void {
135
143
  return () => {
136
144
  document.removeEventListener('click', onClickSidebarRow, true)
137
145
  document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
138
- waitObserver.disconnect()
146
+ unsubscribeBody()
139
147
  unsubscribe()
140
148
  unsubscribeLocale?.()
141
149
  document.documentElement.removeAttribute(options.activeAttribute)
@@ -14,6 +14,7 @@
14
14
  * Packages receive this file as a generated copy via scripts/sync-shared.mjs;
15
15
  * edit the shared source and re-run the sync instead of editing a copy.
16
16
  */
17
+ import { subscribeBodyInvalidations } from './body-mutations.ts'
17
18
 
18
19
  /** Per-package configuration for one sidebar entry row. */
19
20
  export interface SidebarEntryOptions {
@@ -188,13 +189,15 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
188
189
 
189
190
  // Body-level watcher retained as the "whole rebuild" fallback: when the shell
190
191
  // tears down the whole sidebar pane, the root observer is gone with it and
191
- // only this body observation can notice the new pane mounting. It is no
192
- // longer disconnected after placement; the placed-and-still-mounted case
192
+ // only this body observation can notice the new pane mounting. It stays
193
+ // subscribed after placement; the placed-and-still-mounted case
193
194
  // short-circuits through the cheap document.body.contains(entry) check, so
194
- // unrelated app mutations (e.g. chat streaming) cost one contains check
195
- // instead of churning the full re-query.
196
- const waitObserver = new MutationObserver(() => { tryPlace() })
197
- waitObserver.observe(document.body, { childList: true, subtree: true })
195
+ // unrelated app mutations (e.g. chat streaming) cost one contains check per
196
+ // frame instead of churning the full re-query. The body observation itself is
197
+ // the page-wide hub (shared/client/body-mutations.ts): every family plugin
198
+ // used to hold its own body subtree observer, so the per-mutation cost grew
199
+ // with the number of installed plugins; the hub keeps exactly one.
200
+ const unsubscribeBody = subscribeBodyInvalidations(() => { tryPlace() })
198
201
 
199
202
  // Self-heal: if a React re-render displaces the row, re-insert it in the
200
203
  // same frame (microtask before paint -> no visible flicker).
@@ -225,7 +228,7 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
225
228
  tryPlace()
226
229
 
227
230
  return () => {
228
- waitObserver.disconnect()
231
+ unsubscribeBody()
229
232
  rootObserver.disconnect()
230
233
  unsubscribeRefresh?.()
231
234
  unsubscribeActive?.()
@@ -327,9 +327,8 @@ export class BoardController {
327
327
  }
328
328
 
329
329
  /**
330
- * Archive a settled task (done/failed). Running or on-board-unsettled
331
- * tasks are refused so the runner keeps exclusive ownership of their
332
- * lifecycle.
330
+ * Archive a task from any status but `running`, whose lifecycle the runner
331
+ * keeps exclusive ownership of until it settles.
333
332
  * @returns true when applied.
334
333
  */
335
334
  archiveTask(id: string): boolean {
@@ -179,7 +179,10 @@ function parseField(field: string, min: number, max: number, out: Set<number>):
179
179
  high = Number(b)
180
180
  } else if (isDigits(range)) {
181
181
  low = Number(range)
182
- high = Number(range)
182
+ // Standard cron: a bare value carrying a step runs from that value to the
183
+ // field maximum (minutes 5/15 are 5,20,35,50), while a bare value alone is
184
+ // exactly itself (#1493).
185
+ high = stepRaw === undefined ? low : max
183
186
  } else {
184
187
  return false
185
188
  }
package/src/core/tasks.ts CHANGED
@@ -172,8 +172,13 @@ export interface TaskRecord {
172
172
  archivedAt?: number
173
173
  }
174
174
 
175
- /** Statuses a settled task may be archived from. */
176
- export const ARCHIVABLE_STATUSES: readonly TaskStatus[] = ['done', 'failed']
175
+ /**
176
+ * Statuses a task may be archived from: every status but `running`, whose
177
+ * execution the runner still owns until it settles. A settled-only gate made
178
+ * the duplicate-and-archive flow a silent no-op for scheduled tasks, which
179
+ * return to `todo` after every successful run (issue #1447).
180
+ */
181
+ export const ARCHIVABLE_STATUSES: readonly TaskStatus[] = ['backlog', 'todo', 'done', 'failed']
177
182
 
178
183
 
179
184
  /** Permission presets a task may pin on its execution session (the `/permission <id>` ids). */