@linxin666/dsh-client-ui-task-board 0.2.0 → 0.2.2

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 (37) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +1 -1
  3. package/README.zh.md +1 -1
  4. package/lib/client.js +349 -73
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +161 -31
  7. package/lib/types/client/PluginSettingsCard.d.ts +22 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +2 -2
  9. package/lib/types/client/host-api.d.ts +3 -3
  10. package/lib/types/client/sidebar-entry-core.d.ts +60 -0
  11. package/lib/types/client/sidebar-entry.d.ts +8 -10
  12. package/lib/types/core/controller.d.ts +9 -2
  13. package/lib/types/core/schedule.d.ts +7 -0
  14. package/lib/types/host-ledger.d.ts +10 -0
  15. package/lib/types/host-routes.d.ts +3 -1
  16. package/lib/types/host-runner.d.ts +12 -1
  17. package/lib/types/host-service.d.ts +5 -1
  18. package/lib/types/protocol.d.ts +6 -0
  19. package/package.json +1 -2
  20. package/src/client/PluginSettingsCard.tsx +219 -18
  21. package/src/client/TaskBoardSettingsCard.tsx +10 -10
  22. package/src/client/board/TaskBoard.tsx +5 -5
  23. package/src/client/board/TaskCard.tsx +1 -0
  24. package/src/client/board/TaskDetail.tsx +1 -1
  25. package/src/client/board-mount.tsx +1 -0
  26. package/src/client/host-api.ts +12 -3
  27. package/src/client/settings-card.module.css +90 -1
  28. package/src/client/sidebar-entry-core.ts +208 -0
  29. package/src/client/sidebar-entry.ts +24 -144
  30. package/src/core/controller.ts +20 -3
  31. package/src/core/execution.ts +1 -2
  32. package/src/core/schedule.ts +57 -10
  33. package/src/host-ledger.ts +67 -6
  34. package/src/host-routes.ts +11 -3
  35. package/src/host-runner.ts +24 -6
  36. package/src/host-service.ts +24 -7
  37. package/src/protocol.ts +7 -0
@@ -0,0 +1,208 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/sidebar-entry-core.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Shared sidebar entry injection core.
4
+ *
5
+ * dsh's sidebar shell exposes no slot an external plugin can register into,
6
+ * so the entry row is injected between the shell's New Session button and the
7
+ * workspace browser. The injection self-heals: a MutationObserver watches the
8
+ * sidebar root and re-inserts the row whenever a React re-render displaces it
9
+ * (re-insertion happens in the same frame, before paint, so no flicker).
10
+ *
11
+ * The row is plain DOM (no React tree) so it can never disturb the shell's
12
+ * reconciliation; the view it toggles is a separate root owned by the caller.
13
+ *
14
+ * Packages receive this file as a generated copy via scripts/sync-shared.mjs;
15
+ * edit the shared source and re-run the sync instead of editing a copy.
16
+ */
17
+
18
+ /** Per-package configuration for one sidebar entry row. */
19
+ export interface SidebarEntryOptions {
20
+ /** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
21
+ rowAttribute: string
22
+ /** CSS selector matching the injected row, e.g. '[data-dsh-ssh-entry]'. */
23
+ rowSelector: string
24
+ /**
25
+ * L2 semantic-attribute plugin id (issue #506, enum table:
26
+ * skins/skin-center/contracts/semantic-attrs-v1.md). When set, the row also
27
+ * outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
28
+ * leaves the row without semantic attributes.
29
+ */
30
+ plugin?: string
31
+ /** Inline icon markup (matches the shell's 16px nav-icon look). */
32
+ icon: string
33
+ /** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
34
+ css: Record<string, string>
35
+ /** Localized row label (aria-label + visible text). */
36
+ label(): string
37
+ /** Optional localized tooltip (title attribute). */
38
+ tooltip?(): string
39
+ /** Click action (open/toggle the owning panel). */
40
+ onToggle(): void
41
+ /** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
42
+ position: 'before' | 'after'
43
+ /**
44
+ * Selectors of the sibling plugin entry rows this package orders against
45
+ * (its own row included — the placement guard excludes a row that is
46
+ * already inside the root). Each package passes the same list it used
47
+ * before the consolidation so the rendered order stays stable.
48
+ */
49
+ familySelectors: readonly string[]
50
+ /** Optional active-state bridge; highlights the row while the panel is open. */
51
+ active?: {
52
+ subscribe(listener: () => void): () => void
53
+ isOpen(): boolean
54
+ }
55
+ }
56
+
57
+ /** Find the sidebar shell root element, or undefined while not yet mounted. */
58
+ function sidebarRoot(): HTMLElement | undefined {
59
+ const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
60
+ if (column === null) return undefined
61
+ // Current shells wrap the sidebar UI: column > wrapper > root(logoRow owner).
62
+ // Prefer the element that owns the logo row — the real sidebar UI root —
63
+ // and fall back to the column's first child for legacy shells.
64
+ const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
65
+ return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
66
+ }
67
+
68
+ /** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
69
+ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
70
+ const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
71
+ if (nested !== null) return nested
72
+ for (const child of root.children) {
73
+ if (child.tagName === 'BUTTON') return child as HTMLButtonElement
74
+ }
75
+ return undefined
76
+ }
77
+
78
+ /** Build the entry row (a detached button; insert once the shell is up). */
79
+ function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
80
+ const entry = document.createElement('button')
81
+ entry.type = 'button'
82
+ entry.setAttribute(options.rowAttribute, '')
83
+ if (options.plugin !== undefined) {
84
+ entry.setAttribute('data-dsh-plugin', options.plugin)
85
+ entry.setAttribute('data-dsh-part', 'sidebar-entry')
86
+ }
87
+ entry.className = options.css['entry'] ?? ''
88
+ entry.setAttribute('aria-label', options.label())
89
+ if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
90
+ entry.innerHTML = '<span class="' + (options.css['entryIcon'] ?? '') + '">' + options.icon
91
+ + '</span><span class="' + (options.css['entryLabel'] ?? '') + '">' + options.label() + '</span>'
92
+ entry.addEventListener('click', options.onToggle)
93
+ return entry
94
+ }
95
+
96
+ /** Re-insert the entry after the New Session row (before the browser region). */
97
+ function placeEntry(root: HTMLElement, entry: HTMLButtonElement, options: SidebarEntryOptions): boolean {
98
+ const button = newSessionButton(root)
99
+ if (button === undefined) return false
100
+ if (entry.parentElement !== root) {
101
+ // Position relative to the family block (entries injected by sibling
102
+ // plugins), never relative to transient logoRow geometry: every family
103
+ // plugin that self-heals during a re-render then lands in the same
104
+ // relative order, so the entries cannot swap positions regardless of
105
+ // observer callback order or of shell wrapper changes. There is no
106
+ // append-to-end fallback: appending at the end would randomly reorder
107
+ // the block after a shell re-render.
108
+ const row = button.closest('[class*="logoRow"]')
109
+ const base = (row !== null && row.parentElement === root) ? row : button
110
+ const family = Array.from(root.children).filter(
111
+ (el): el is HTMLElement => el instanceof HTMLElement && el.matches(options.familySelectors.join(', ')),
112
+ )
113
+ const anchor = options.position === 'before'
114
+ ? (family.length > 0 ? family[0] : base.nextElementSibling)
115
+ : (family.length > 0 ? family[family.length - 1]!.nextElementSibling : base.nextElementSibling)
116
+ root.insertBefore(entry, anchor)
117
+ }
118
+ return true
119
+ }
120
+
121
+ /**
122
+ * Mount the sidebar entry, waiting for the shell to render and self-healing
123
+ * on later React re-renders.
124
+ * @param options - the row's attribute/icon/copy/action/ordering configuration.
125
+ * @returns disposer removing the entry and its observers.
126
+ */
127
+ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
128
+ // DOM-level idempotency: whatever path mounted an entry row before this
129
+ // call (a duplicated apply, an HMR re-injection, a stale module still
130
+ // alive), never mount a second one. The existing row keeps working; a full
131
+ // page reload is the ultimate reset.
132
+ if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
133
+ return () => {}
134
+ }
135
+ const entry = createEntry(options)
136
+ let root: HTMLElement | undefined
137
+ let placed = false
138
+
139
+ const tryPlace = (): void => {
140
+ if (root !== undefined && !root.isConnected) {
141
+ // The shell rebuilt the sidebar pane (whole-tree teardown); the root
142
+ // observer is gone with the old tree, so detach it and re-query from
143
+ // scratch. The new pane is later noticed by the body-level watcher.
144
+ rootObserver.disconnect()
145
+ root = undefined
146
+ placed = false
147
+ }
148
+ if (placed) {
149
+ // Cheap short-circuit: entry still lives in a mountable subtree.
150
+ if (document.body.contains(entry)) return
151
+ // Entry was torn down together with the old tree; reset and re-place.
152
+ rootObserver.disconnect()
153
+ root = undefined
154
+ placed = false
155
+ }
156
+ root ??= sidebarRoot()
157
+ if (root === undefined) return
158
+ placed = placeEntry(root, entry, options)
159
+ if (placed) {
160
+ rootObserver.observe(root, { childList: true, subtree: true })
161
+ }
162
+ }
163
+
164
+ // Body-level watcher retained as the "whole rebuild" fallback: when the shell
165
+ // tears down the whole sidebar pane, the root observer is gone with it and
166
+ // only this body observation can notice the new pane mounting. It is no
167
+ // longer disconnected after placement; the placed-and-still-mounted case
168
+ // short-circuits through the cheap document.body.contains(entry) check, so
169
+ // unrelated app mutations (e.g. chat streaming) cost one contains check
170
+ // instead of churning the full re-query.
171
+ const waitObserver = new MutationObserver(() => { tryPlace() })
172
+ waitObserver.observe(document.body, { childList: true, subtree: true })
173
+
174
+ // Self-heal: if a React re-render displaces the row, re-insert it in the
175
+ // same frame (microtask before paint -> no visible flicker).
176
+ const rootObserver = new MutationObserver(() => {
177
+ if (root === undefined || !root.isConnected) {
178
+ placed = false
179
+ tryPlace()
180
+ return
181
+ }
182
+ if (!root.contains(entry)) {
183
+ placed = placeEntry(root, entry, options)
184
+ }
185
+ })
186
+
187
+ // Reflect the panel's open state on the row (active highlight). Note: assigning
188
+ // undefined to dataset.active materializes data-active="undefined" and keeps the
189
+ // row permanently highlighted — delete the attribute instead.
190
+ const unsubscribeActive = options.active === undefined ? undefined : (() => {
191
+ const syncActive = (): void => {
192
+ if (options.active!.isOpen()) entry.dataset.active = 'true'
193
+ else delete entry.dataset.active
194
+ }
195
+ const unsubscribe = options.active.subscribe(syncActive)
196
+ syncActive()
197
+ return unsubscribe
198
+ })()
199
+
200
+ tryPlace()
201
+
202
+ return () => {
203
+ waitObserver.disconnect()
204
+ rootObserver.disconnect()
205
+ unsubscribeActive?.()
206
+ entry.remove()
207
+ }
208
+ }
@@ -1,84 +1,26 @@
1
1
  /**
2
- * Sidebar entry injection.
2
+ * Sidebar entry injection — package-specific wiring over the shared core.
3
3
  *
4
4
  * dsh's sidebar shell exposes no slot an external plugin can register into
5
5
  * (`sidebar.workspaces` / `sidebar.settings` are single-occupant and already
6
- * taken), so — following the skin precedent of DOM-level extension — the
7
- * entry row is injected between the shell's New Session button and the
8
- * workspace browser. The injection self-heals: a MutationObserver watches the
9
- * sidebar root and re-inserts the row whenever a React re-render displaces it
10
- * (re-insertion happens in the same frame, before paint, so no flicker).
11
- *
12
- * The row is plain DOM (no React tree) so it can never disturb the shell's
13
- * reconciliation; the board view it toggles is a separate React root mounted
14
- * in the center column (see board-mount.ts).
6
+ * taken), so the entry row is injected between the shell's New Session button
7
+ * and the workspace browser. The DOM injection / self-healing / idempotency
8
+ * logic lives exactly once in shared/client/sidebar-entry-core.ts (synced
9
+ * copy); this wrapper supplies the task-board icon, copy, CSS module, and the
10
+ * board toggle. The row is plain DOM (no React tree); the board view it
11
+ * toggles is a separate React root mounted in the center column
12
+ * (see board-mount.ts).
15
13
  */
16
14
  import type { BoardController } from '../core/controller.ts'
17
15
  import { t } from './locales.ts'
18
16
  import css from './board.module.css'
17
+ import { mountSidebarEntry as mountSharedSidebarEntry } from './sidebar-entry-core.ts'
19
18
 
20
19
  /** Stable data attribute identifying the injected entry row. */
21
20
  export const ENTRY_SELECTOR = '[data-dsh-taskboard-entry]'
22
21
 
23
22
  /** Inline icon (matches the shell's 16px nav-icon look). */
24
- const ICON = `<svg viewBox="0 0 16 16" width="14" height="14" 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>`
25
-
26
- /** Find the sidebar shell root element, or undefined while not yet mounted. */
27
- function sidebarRoot(): HTMLElement | undefined {
28
- const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
29
- if (column === null) return undefined
30
- // Current shells wrap the sidebar UI: column > wrapper > root(logoRow owner).
31
- // Prefer the element that owns the logo row — the real sidebar UI root —
32
- // and fall back to the column's first child for legacy shells.
33
- const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
34
- return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
35
- }
36
-
37
- /** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
38
- function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
39
- const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
40
- if (nested !== null) return nested
41
- for (const child of root.children) {
42
- if (child.tagName === 'BUTTON') return child as HTMLButtonElement
43
- }
44
- return undefined
45
- }
46
-
47
- /** Build the entry row (a detached button; insert once the shell is up). */
48
- function createEntry(controller: BoardController): HTMLButtonElement {
49
- const entry = document.createElement('button')
50
- entry.type = 'button'
51
- entry.dataset.dshTaskboardEntry = ''
52
- entry.className = css.entry
53
- entry.setAttribute('aria-label', t('entry.label'))
54
- entry.innerHTML = `<span class="${css.entryIcon}">${ICON}</span><span class="${css.entryLabel}">${t('entry.label')}</span>`
55
- entry.addEventListener('click', () => { controller.toggleBoard() })
56
- return entry
57
- }
58
-
59
- /** Re-insert the entry after the New Session row (before the browser region). */
60
- function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
61
- const button = newSessionButton(root)
62
- if (button === undefined) return false
63
- if (entry.parentElement !== root) {
64
- // Position relative to the family block (entries injected by sibling
65
- // plugins), never relative to transient logoRow geometry: every family
66
- // plugin that self-heals during a re-render then lands in the same
67
- // relative order, so the entries cannot swap positions regardless of
68
- // observer callback order or of shell wrapper changes. There is no
69
- // append-to-end fallback: appending at the end would randomly reorder
70
- // the block after a shell re-render.
71
- const row = button.closest('[class*="logoRow"]')
72
- const base = (row !== null && row.parentElement === root) ? row : button
73
- const family = Array.from(root.children).filter(
74
- (el): el is HTMLElement => el instanceof HTMLElement && el.matches('[data-dsh-taskboard-entry], [data-dsh-ssh-entry]'),
75
- )
76
- // task board sits before the whole family block.
77
- const anchor = family.length > 0 ? family[0] : base.nextElementSibling
78
- root.insertBefore(entry, anchor)
79
- }
80
- return true
81
- }
23
+ const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" 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>'
82
24
 
83
25
  /**
84
26
  * Mount the sidebar entry, waiting for the shell to render and self-healing
@@ -87,81 +29,19 @@ function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
87
29
  * @returns disposer removing the entry and its observers.
88
30
  */
89
31
  export function mountSidebarEntry(controller: BoardController): () => void {
90
- // DOM-level idempotency: whatever path mounted an entry row before this
91
- // call (a duplicated apply, an HMR re-injection, a stale module still
92
- // alive), never mount a second one. The existing row keeps working; a full
93
- // page reload is the ultimate reset.
94
- if (typeof document !== 'undefined' && document.querySelector('[data-dsh-taskboard-entry]') !== null) {
95
- return () => {}
96
- }
97
- const entry = createEntry(controller)
98
- let root: HTMLElement | undefined
99
- let placed = false
100
-
101
- const tryPlace = (): void => {
102
- if (root !== undefined && !root.isConnected) {
103
- // The shell rebuilt the sidebar pane (whole-tree teardown); the root
104
- // observer is gone with the old tree, so detach it and re-query from
105
- // scratch. The new pane is later noticed by the body-level watcher.
106
- rootObserver.disconnect()
107
- root = undefined
108
- placed = false
109
- }
110
- if (placed) {
111
- // Cheap short-circuit: entry still lives in a mountable subtree.
112
- if (document.body.contains(entry)) return
113
- // Entry was torn down together with the old tree; reset and re-place.
114
- rootObserver.disconnect()
115
- root = undefined
116
- placed = false
117
- }
118
- root ??= sidebarRoot()
119
- if (root === undefined) return
120
- placed = placeEntry(root, entry)
121
- if (placed) {
122
- rootObserver.observe(root, { childList: true, subtree: true })
123
- }
124
- }
125
-
126
- // Body-level watcher retained as the "whole rebuild" fallback: when the shell
127
- // tears down the whole sidebar pane, the root observer is gone with it and
128
- // only this body observation can notice the new pane mounting. It is no
129
- // longer disconnected after placement; the placed-and-still-mounted case
130
- // short-circuits through the cheap document.body.contains(entry) check, so
131
- // unrelated app mutations (e.g. chat streaming) cost one contains check
132
- // instead of churning the full re-query.
133
- const waitObserver = new MutationObserver(() => { tryPlace() })
134
- waitObserver.observe(document.body, { childList: true, subtree: true })
135
-
136
- // Self-heal: if a React re-render displaces the row, re-insert it in the
137
- // same frame (microtask before paint → no visible flicker).
138
- const rootObserver = new MutationObserver(() => {
139
- if (root === undefined || !root.isConnected) {
140
- placed = false
141
- tryPlace()
142
- return
143
- }
144
- if (!root.contains(entry)) {
145
- placed = placeEntry(root, entry)
146
- }
32
+ return mountSharedSidebarEntry({
33
+ rowAttribute: 'data-dsh-taskboard-entry',
34
+ rowSelector: ENTRY_SELECTOR,
35
+ plugin: 'task-board',
36
+ icon: ICON,
37
+ css,
38
+ label: () => t('entry.label'),
39
+ onToggle: () => { controller.toggleBoard() },
40
+ position: 'before',
41
+ familySelectors: ['[data-dsh-taskboard-entry]', '[data-dsh-ssh-entry]'],
42
+ active: {
43
+ subscribe: (listener) => controller.subscribe(listener),
44
+ isOpen: () => controller.getSnapshot().boardOpen,
45
+ },
147
46
  })
148
-
149
- // Reflect the board's open state on the row (active highlight). Note: assigning
150
- // undefined to dataset.active materializes data-active="undefined" and keeps the
151
- // row permanently highlighted — delete the attribute instead.
152
- const syncActive = () => {
153
- if (controller.getSnapshot().boardOpen) entry.dataset.active = 'true'
154
- else delete entry.dataset.active
155
- }
156
- const unsubscribe = controller.subscribe(syncActive)
157
- syncActive()
158
-
159
- tryPlace()
160
-
161
- return () => {
162
- waitObserver.disconnect()
163
- rootObserver.disconnect()
164
- unsubscribe()
165
- entry.remove()
166
- }
167
47
  }
@@ -23,13 +23,13 @@ import { applyCreateTask } from './use-cases/task-create.ts'
23
23
  import { applyDeleteTask } from './use-cases/task-delete.ts'
24
24
  import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
25
25
  import { applyUpdateTask, type TaskUpdatePatch } from './use-cases/task-update.ts'
26
- import type { TaskBoardAction, TaskBoardSnapshot } from '../protocol.ts'
26
+ import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardSnapshot } from '../protocol.ts'
27
27
 
28
28
  export interface TaskBoardTransport {
29
29
  bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>
30
30
  state(): Promise<TaskBoardSnapshot>
31
31
  action(action: TaskBoardAction): Promise<TaskBoardSnapshot>
32
- subscribe(listener: () => void): () => void
32
+ subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void
33
33
  }
34
34
 
35
35
  /** The sessions face the controller needs for navigation awareness. */
@@ -601,7 +601,7 @@ export class BoardController {
601
601
  this.acceptRemote(await transport.bootstrap(this.tasks))
602
602
  if (!this.remoteSubscribed) {
603
603
  this.remoteSubscribed = true
604
- this.disposers.push(transport.subscribe(() => { void this.refreshRemote() }))
604
+ this.disposers.push(transport.subscribe((event) => { this.onRemoteEvent(event) }))
605
605
  }
606
606
  return true
607
607
  } catch (error) {
@@ -611,6 +611,23 @@ export class BoardController {
611
611
  }
612
612
  }
613
613
 
614
+ /**
615
+ * SSE frames carry revision/scheduler/power. When the revision matches the
616
+ * one already applied, apply the frame's scheduler/power in place and skip
617
+ * the full /state fetch; otherwise the 5 s heartbeat would re-clone and
618
+ * re-serialize the whole ledger per tab even while nothing changes.
619
+ */
620
+ private onRemoteEvent(event: TaskBoardEventPayload | undefined): void {
621
+ if (event !== undefined && this.hostState !== undefined && event.revision === this.hostState.revision
622
+ && typeof event.scheduler === 'object' && event.scheduler !== null
623
+ && typeof event.power === 'object' && event.power !== null) {
624
+ this.hostState = { revision: event.revision, scheduler: event.scheduler, power: event.power }
625
+ this.notify()
626
+ return
627
+ }
628
+ void this.refreshRemote()
629
+ }
630
+
614
631
  private async refreshRemote(preserveError?: string): Promise<boolean> {
615
632
  const transport = this.deps.transport
616
633
  if (transport === undefined) return true
@@ -174,7 +174,7 @@ export class ExecutionService {
174
174
  // command must run before the task's turn starts. A rejected target
175
175
  // fails the run without sending the prompt — running the task under
176
176
  // different settings than it declared would be worse than not running.
177
- if (!await this.applyMode(driver, task, sessionId, settleFailed)) return
177
+ if (!await this.applyMode(task, sessionId, settleFailed)) return
178
178
  if (!await this.applyPermission(driver, task, settleFailed)) return
179
179
  // Best-effort rename so the execution is recognizable in the session list.
180
180
  await driver.rename(task.title).catch(() => { /* rename is cosmetic */ })
@@ -200,7 +200,6 @@ export class ExecutionService {
200
200
  * the wire refuses.
201
201
  */
202
202
  private async applyMode(
203
- driver: SessionDriver,
204
203
  task: TaskRecord,
205
204
  sessionId: string,
206
205
  settleFailed: (error: string) => void,
@@ -77,22 +77,73 @@ export function isValidCron(expr: string): boolean {
77
77
  * can never match (for example `0 0 30 2 *`). The five-year horizon includes
78
78
  * a full leap cycle, so a valid February 29 schedule remains reachable from
79
79
  * every non-leap year.
80
+ *
81
+ * Walks candidate year/month/day/hour/minute values straight from the parsed
82
+ * field sets instead of scanning every minute: a sparse expression such as
83
+ * `0 0 29 2 *` used to iterate ~1.5M wall-clock minutes before reaching the
84
+ * next leap day. Wall-clock field construction + the final `matches` re-check
85
+ * preserve the old minute scan's DST semantics exactly (nonexistent spring
86
+ * minutes normalize forward and the repeated fall-back hour is never visited).
80
87
  */
81
88
  export function nextRunAtMs(expr: string, fromMs: number): number | undefined {
82
89
  const schedule = parseCron(expr)
83
90
  if (schedule === null) return undefined
84
91
  if (!hasPossibleCalendarDay(schedule)) return undefined
85
92
  const from = new Date(fromMs)
86
- // Scan from the next minute; Date rolls overflow (Feb 30 → Mar 2) for free.
87
- const scan = new Date(from.getFullYear(), from.getMonth(), from.getDate(), from.getHours(), from.getMinutes() + 1, 0, 0)
88
93
  const limitMs = fromMs + 5 * 366 * 24 * 60 * 60 * 1000
89
- while (scan.getTime() <= limitMs) {
90
- if (matches(schedule, scan)) return scan.getTime()
91
- scan.setMinutes(scan.getMinutes() + 1)
94
+
95
+ const sortedMinutes = [...schedule.minutes].sort((a, b) => a - b)
96
+ const sortedHours = [...schedule.hours].sort((a, b) => a - b)
97
+ const sortedMonths = [...schedule.months].sort((a, b) => a - b)
98
+
99
+ let year = from.getFullYear()
100
+ let month = from.getMonth() + 1
101
+ let day = from.getDate()
102
+ let hour = from.getHours()
103
+ // Strictly after fromMs: the old scan started from the next minute.
104
+ let minute = from.getMinutes() + 1
105
+
106
+ while (new Date(year, month - 1, 1, 0, 0, 0, 0).getTime() <= limitMs) {
107
+ for (const candidateMonth of sortedMonths) {
108
+ if (candidateMonth < month) continue
109
+ const daysInMonth = new Date(year, candidateMonth, 0).getDate()
110
+ const dayStart = candidateMonth === month ? day : 1
111
+ for (let candidateDay = dayStart; candidateDay <= daysInMonth; candidateDay += 1) {
112
+ const dayProbe = new Date(year, candidateMonth - 1, candidateDay, 0, 0, 0, 0)
113
+ if (!dayCandidate(schedule, dayProbe)) continue
114
+ const hourStart = candidateMonth === month && candidateDay === day ? hour : 0
115
+ for (const candidateHour of sortedHours) {
116
+ if (candidateHour < hourStart) continue
117
+ const minuteStart = candidateMonth === month && candidateDay === day && candidateHour === hour ? minute : 0
118
+ for (const candidateMinute of sortedMinutes) {
119
+ if (candidateMinute < minuteStart) continue
120
+ const candidate = new Date(year, candidateMonth - 1, candidateDay, candidateHour, candidateMinute, 0, 0)
121
+ const time = candidate.getTime()
122
+ if (time <= fromMs) continue
123
+ if (time > limitMs) return undefined
124
+ if (matches(schedule, candidate)) return time
125
+ }
126
+ }
127
+ }
128
+ }
129
+ year += 1
130
+ month = 1
131
+ day = 1
132
+ hour = 0
133
+ minute = 0
92
134
  }
93
135
  return undefined
94
136
  }
95
137
 
138
+ /** Day/weekday OR gate shared by {@link matches} and the candidate scan. */
139
+ function dayCandidate(schedule: CronSchedule, date: Date): boolean {
140
+ const dayMatches = schedule.days.has(date.getDate())
141
+ const weekdayMatches = schedule.weekdays.has(date.getDay())
142
+ if (schedule.dayWildcard) return weekdayMatches
143
+ if (schedule.weekdayWildcard) return dayMatches
144
+ return dayMatches || weekdayMatches
145
+ }
146
+
96
147
  /** Reject impossible month/day pairs without spending the multi-year scan. */
97
148
  function hasPossibleCalendarDay(schedule: CronSchedule): boolean {
98
149
  if (schedule.dayWildcard || !schedule.weekdayWildcard) return true
@@ -145,11 +196,7 @@ function matches(schedule: CronSchedule, date: Date): boolean {
145
196
  if (!schedule.minutes.has(date.getMinutes())) return false
146
197
  if (!schedule.hours.has(date.getHours())) return false
147
198
  if (!schedule.months.has(date.getMonth() + 1)) return false
148
- const dayMatches = schedule.days.has(date.getDate())
149
- const weekdayMatches = schedule.weekdays.has(date.getDay())
150
- if (schedule.dayWildcard) return weekdayMatches
151
- if (schedule.weekdayWildcard) return dayMatches
152
- return dayMatches || weekdayMatches
199
+ return dayCandidate(schedule, date)
153
200
  }
154
201
 
155
202
  function isDigits(value: string): boolean {