@linxin666/dsh-client-ui-task-board 0.4.2 → 0.4.4

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 (73) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +21 -13
  3. package/README.zh.md +21 -13
  4. package/lib/client.js +1566 -929
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +934 -176
  7. package/lib/types/client/board/TaskForm.d.ts +19 -1
  8. package/lib/types/client/board/preset-label.d.ts +22 -0
  9. package/lib/types/client/host-api.d.ts +24 -1
  10. package/lib/types/client/index.d.ts +15 -1
  11. package/lib/types/client/locales.d.ts +38 -2
  12. package/lib/types/client/native-panel.d.ts +62 -0
  13. package/lib/types/client/schedule-zone.d.ts +47 -0
  14. package/lib/types/client/settings-entry-form.d.ts +49 -0
  15. package/lib/types/core/controller.d.ts +42 -2
  16. package/lib/types/core/degraded-reason.d.ts +46 -0
  17. package/lib/types/core/package-name.d.ts +9 -0
  18. package/lib/types/core/schedule.d.ts +95 -20
  19. package/lib/types/core/subtask.d.ts +30 -11
  20. package/lib/types/core/tasks.d.ts +26 -2
  21. package/lib/types/core/use-cases/task-create.d.ts +2 -1
  22. package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
  23. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  24. package/lib/types/host-ledger.d.ts +65 -4
  25. package/lib/types/host-runner.d.ts +76 -3
  26. package/lib/types/host-service.d.ts +77 -5
  27. package/lib/types/index.d.ts +11 -1
  28. package/lib/types/mount-once.d.ts +38 -7
  29. package/lib/types/protocol.d.ts +9 -3
  30. package/package.json +26 -23
  31. package/src/client/board/LinkSubtaskModal.tsx +21 -11
  32. package/src/client/board/NewTaskModal.tsx +271 -150
  33. package/src/client/board/TaskCard.tsx +24 -5
  34. package/src/client/board/TaskDetail.tsx +83 -33
  35. package/src/client/board/TaskForm.tsx +48 -3
  36. package/src/client/board/preset-label.ts +44 -0
  37. package/src/client/board.module.css +130 -136
  38. package/src/client/host-api.ts +109 -6
  39. package/src/client/index.ts +50 -18
  40. package/src/client/locales.ts +82 -8
  41. package/src/client/native-panel.tsx +124 -0
  42. package/src/client/schedule-zone.ts +135 -0
  43. package/src/client/settings-entry-form.ts +142 -0
  44. package/src/core/controller.ts +66 -4
  45. package/src/core/degraded-reason.ts +81 -0
  46. package/src/core/package-name.ts +9 -0
  47. package/src/core/schedule.ts +211 -55
  48. package/src/core/store.ts +10 -1
  49. package/src/core/subtask.ts +77 -22
  50. package/src/core/tasks.ts +38 -4
  51. package/src/core/use-cases/task-create.ts +14 -2
  52. package/src/core/use-cases/task-schedule.ts +17 -6
  53. package/src/core/use-cases/task-update.ts +4 -1
  54. package/src/host/agent-tools.ts +40 -8
  55. package/src/host-ledger.ts +223 -23
  56. package/src/host-routes.ts +37 -1
  57. package/src/host-runner.ts +164 -14
  58. package/src/host-service.ts +232 -39
  59. package/src/index.ts +23 -1
  60. package/src/mount-once.ts +107 -13
  61. package/src/protocol.ts +27 -8
  62. package/lib/types/client/board-mount.d.ts +0 -23
  63. package/lib/types/client/body-mutations.d.ts +0 -45
  64. package/lib/types/client/panel-mount-core.d.ts +0 -56
  65. package/lib/types/client/sidebar-entry-core.d.ts +0 -55
  66. package/lib/types/client/sidebar-entry.d.ts +0 -29
  67. package/lib/types/host/run-guarded.d.ts +0 -39
  68. package/src/client/board-mount.tsx +0 -43
  69. package/src/client/body-mutations.ts +0 -133
  70. package/src/client/panel-mount-core.ts +0 -155
  71. package/src/client/sidebar-entry-core.ts +0 -237
  72. package/src/client/sidebar-entry.ts +0 -53
  73. package/src/host/run-guarded.ts +0 -76
@@ -24,28 +24,40 @@ export declare const DEFAULT_SUBTASK_DEPTH = 1;
24
24
  export declare function normalizeSubtaskDepth(value: unknown): number;
25
25
  /** Whether an unknown value is a settled execution outcome. */
26
26
  export declare function isExecutionOutcome(value: unknown): value is ExecutionOutcome;
27
+ /**
28
+ * Reusable lineage index over ONE task list: the id lookup and the direct
29
+ * children per parent. A caller that runs several lineage queries over the same
30
+ * ledger (the link-subtask candidate gate runs one per task) builds this once
31
+ * instead of rescanning the list per query.
32
+ */
33
+ export interface LineageIndex {
34
+ readonly byId: ReadonlyMap<string, TaskRecord>;
35
+ readonly childrenByParent: ReadonlyMap<string, readonly TaskRecord[]>;
36
+ }
37
+ /** Build the reusable index for a task list (ledger order preserved). */
38
+ export declare function buildLineageIndex(tasks: readonly TaskRecord[]): LineageIndex;
27
39
  /**
28
40
  * Nearest-first ancestor chain of a task. The walk is cycle-safe and bounded
29
41
  * by the ledger size rather than by the deployment maximum: a chain stored
30
42
  * under a larger limit survives that limit being lowered, and restore and
31
43
  * inheritance must still see all of it.
32
44
  */
33
- export declare function ancestorChain(tasks: readonly TaskRecord[], task: TaskRecord): TaskRecord[];
45
+ export declare function ancestorChain(tasks: readonly TaskRecord[], task: TaskRecord, lineage?: LineageIndex): TaskRecord[];
34
46
  /** Root-to-task depth: a root task is 0, its subtask 1, and so on. */
35
- export declare function taskDepth(tasks: readonly TaskRecord[], id: string): number;
47
+ export declare function taskDepth(tasks: readonly TaskRecord[], id: string, lineage?: LineageIndex): number;
36
48
  /** Direct subtasks of a task, in ledger order. */
37
- export declare function directSubtasks(tasks: readonly TaskRecord[], id: string): TaskRecord[];
49
+ export declare function directSubtasks(tasks: readonly TaskRecord[], id: string, lineage?: LineageIndex): TaskRecord[];
38
50
  /**
39
51
  * Descendants of a task in breadth-first order, bounded by maxSubtaskDepth
40
52
  * total depth. The visited set keeps a malformed ledger from looping.
41
53
  */
42
- export declare function descendantTasks(tasks: readonly TaskRecord[], id: string, maxSubtaskDepth: number): TaskRecord[];
54
+ export declare function descendantTasks(tasks: readonly TaskRecord[], id: string, maxSubtaskDepth: number, lineage?: LineageIndex): TaskRecord[];
43
55
  /**
44
56
  * Height of a task's own subtree (0 for a leaf), bounded by the hard maximum.
45
57
  * The visited set makes the walk terminate on a cyclic hand-edited ledger
46
58
  * instead of recursing until the stack overflows.
47
59
  */
48
- export declare function subtreeHeight(tasks: readonly TaskRecord[], id: string, visited?: ReadonlySet<string>): number;
60
+ export declare function subtreeHeight(tasks: readonly TaskRecord[], id: string, visited?: ReadonlySet<string>, lineage?: LineageIndex): number;
49
61
  /** Why a parent link was refused; also the source of the Host error text. */
50
62
  export type ParentLinkRejection = 'unknown-task' | 'unknown-parent' | 'archived-parent' | 'self-parent' | 'cycle' | 'depth-exceeded';
51
63
  export interface ParentLinkCheck {
@@ -61,7 +73,7 @@ export interface ParentLinkCheck {
61
73
  * and the resulting depth (parent depth + 1 + the child own subtree height)
62
74
  * must stay within maxSubtaskDepth.
63
75
  */
64
- export declare function checkParentLink(tasks: readonly TaskRecord[], childId: string, parentId: string | null, maxSubtaskDepth: number): ParentLinkCheck;
76
+ export declare function checkParentLink(tasks: readonly TaskRecord[], childId: string, parentId: string | null, maxSubtaskDepth: number, lineage?: LineageIndex): ParentLinkCheck;
65
77
  /**
66
78
  * The tasks one run request opens executions for: the requested task first,
67
79
  * then its on-board descendants within the configured depth. Archived tasks
@@ -110,13 +122,20 @@ export declare function effectiveTaskPermission(task: TaskRecord): TaskRecord['p
110
122
  */
111
123
  /**
112
124
  * The immutable teammate name for one team-mode member. Teammate names are
113
- * lower-kebab-case and must stay unique for the lifetime of the Team, so the
114
- * run-group token is appended: two runs of the same tree never collide. A title
115
- * with no ASCII word characters (a CJK title, for example) keeps a stable
116
- * generic prefix and is still distinguished by the token.
125
+ * lower-kebab-case and must stay unique for the lifetime of the Team: the
126
+ * run-group token separates two runs of the same tree, and the member
127
+ * discriminator separates two members of one run that reduce to the same slug.
128
+ *
129
+ * The discriminator is not cosmetic. A title with no ASCII word characters (a
130
+ * CJK title, for example) carries no slug at all, so every such title of one
131
+ * run group collides on the generic prefix; Agent Teams answers every member
132
+ * after the first with `teammate name "..." was already used in this Team`,
133
+ * and those subtasks never start. Two ASCII titles that share a slug
134
+ * (`AI 强度预测` next to `AI 配比优化`) collide the same way.
117
135
  * @param title - the member task's title.
118
136
  * @param token - the run group id (the task id when no group is recorded).
137
+ * @param memberId - the member task's id, which makes the name unique in the run.
119
138
  * @returns the teammate name handed to the Agent Teams service.
120
139
  */
121
- export declare function teammateName(title: string, token: string): string;
140
+ export declare function teammateName(title: string, token: string, memberId?: string): string;
122
141
  export declare function resolveExecutionTargets(task: TaskRecord, tasks: readonly TaskRecord[], maxSubtaskDepth?: number): TaskRecord;
@@ -75,6 +75,13 @@ export interface ScheduleRule {
75
75
  enabled: boolean;
76
76
  /** 5-field cron expression: `分 时 日 月 周`. */
77
77
  cron: string;
78
+ /**
79
+ * IANA zone the cron wall clock is read in (issue #1722). Absent means the
80
+ * Host process zone, which is what a rule written before zones were
81
+ * persisted keeps using; the Host stamps its own zone at ledger migration so
82
+ * a later `TZ` change cannot silently move an existing rule.
83
+ */
84
+ timeZone?: string;
78
85
  /** Next due instant (ms epoch); maintained by the scheduler/controller. */
79
86
  nextRunAt: number | undefined;
80
87
  /** Instant of the latest scheduled trigger (ms epoch). */
@@ -214,6 +221,16 @@ export interface TaskRecord {
214
221
  * and a subtask's own permission pin cannot be honored in this mode.
215
222
  */
216
223
  teamRun?: boolean;
224
+ /**
225
+ * Start every execution of this task with dsh's built-in `/goal` command,
226
+ * armed with the composed execution prompt as its objective. The session then
227
+ * keeps working automatic continuation rounds until the agent marks the goal
228
+ * complete, and this board settles the execution on the goal's own end
229
+ * instead of at the first turn end. Absent means ON — the option is checked
230
+ * by default, so only an explicit `false` runs the task as a single plain
231
+ * turn. A session whose `/goal` command is refused still runs the prompt.
232
+ */
233
+ goalRun?: boolean;
217
234
  /**
218
235
  * Frozen context snapshot for a continuation card; absent on plain tasks.
219
236
  * Sanitized before it enters the ledger (redaction, slash-command taint,
@@ -282,14 +299,21 @@ export interface NewTaskInput {
282
299
  reuseSession?: boolean;
283
300
  /** Run the subtree as an Agent Team (Team Lead session plus one teammate per direct subtask). */
284
301
  teamRun?: boolean;
302
+ /**
303
+ * Start the execution with dsh's built-in `/goal`. Absent/true keeps the
304
+ * default (goal run); an explicit false requests a single plain turn.
305
+ */
306
+ goalRun?: boolean;
285
307
  /**
286
308
  * Optional scheduled-run rule requested at creation time (the new-task
287
- * dialog): an enable flag plus a 5-field cron expression. The create use
288
- * case arms it only when enabled and the expression is valid.
309
+ * dialog): an enable flag, a 5-field cron expression, and the IANA zone its
310
+ * wall clock is read in (absent means the Host zone). The create use case
311
+ * arms it only when enabled and the expression is valid.
289
312
  */
290
313
  schedule?: {
291
314
  enabled: boolean;
292
315
  cron: string;
316
+ timeZone?: string;
293
317
  };
294
318
  /**
295
319
  * Optional frozen context snapshot (goal/progress/next, sanitized by the
@@ -22,5 +22,6 @@ export interface CreateTaskResult {
22
22
  * @param now - clock instant (ms epoch).
23
23
  * @param id - minted task id.
24
24
  * @param maxSubtaskDepth - deployment subtask depth limit.
25
+ * @param hostTimeZone - zone a rule that stores no zone of its own follows.
25
26
  */
26
- export declare function applyCreateTask(tasks: readonly TaskRecord[], input: NewTaskInput, now: number, id: string, maxSubtaskDepth?: number): CreateTaskResult;
27
+ export declare function applyCreateTask(tasks: readonly TaskRecord[], input: NewTaskInput, now: number, id: string, maxSubtaskDepth?: number, hostTimeZone?: string): CreateTaskResult;
@@ -3,6 +3,8 @@ import { type TaskRecord } from '../tasks.ts';
3
3
  export interface SetSchedulePatch {
4
4
  enabled?: boolean;
5
5
  cron?: string;
6
+ /** IANA zone for the cron wall clock; `null` clears it back to the Host zone. */
7
+ timeZone?: string | null;
6
8
  }
7
9
  /** Result of arming/disarming a rule. */
8
10
  export interface SetScheduleResult {
@@ -12,15 +14,17 @@ export interface SetScheduleResult {
12
14
  applied: boolean;
13
15
  }
14
16
  /**
15
- * Set an on-board task's schedule rule. A blank or invalid cron, or an
16
- * archived task, is rejected (state untouched); an enabled rule computes the
17
- * next run instant immediately, a disabled one carries no next-run instant.
17
+ * Set an on-board task's schedule rule. A blank or invalid cron, an unknown or
18
+ * archived task, or an unusable zone is rejected (state untouched); an enabled
19
+ * rule computes the next run instant immediately in the rule's own zone, a
20
+ * disabled one carries no next-run instant.
18
21
  * @param tasks - current ledger.
19
22
  * @param id - the task to schedule.
20
23
  * @param patch - rule fields to change (absent fields keep their current value).
21
24
  * @param now - clock instant (ms epoch).
25
+ * @param hostTimeZone - zone a rule with no stored zone follows.
22
26
  */
23
- export declare function applySetSchedule(tasks: readonly TaskRecord[], id: string, patch: SetSchedulePatch, now: number): SetScheduleResult;
27
+ export declare function applySetSchedule(tasks: readonly TaskRecord[], id: string, patch: SetSchedulePatch, now: number, hostTimeZone: string): SetScheduleResult;
24
28
  /**
25
29
  * Roll a task's schedule rule forward (scheduler callback): persist the next
26
30
  * due instant and the trigger instant. No-op for tasks without a rule (deleted
@@ -15,7 +15,7 @@ import type { TaskHandoverInput } from '../handover.ts';
15
15
  * Editable fields on a task (the update patch surface). `freeze` replaces the
16
16
  * continuation-card snapshot (restamping frozenAt); an explicit null clears it.
17
17
  */
18
- export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model' | 'reuseSession' | 'teamRun'>> & {
18
+ export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model' | 'reuseSession' | 'teamRun' | 'goalRun'>> & {
19
19
  freeze?: FreezeSnapshot & {
20
20
  redacted?: boolean;
21
21
  } | null;
@@ -23,11 +23,20 @@ export interface OpenExecutionReference {
23
23
  readonly executionId: string;
24
24
  readonly sessionId: string | undefined;
25
25
  readonly startedAt: number;
26
+ /**
27
+ * True for a member of a team-mode run other than its Lead. A teammate is a
28
+ * durable member of the Team: it stays alive after its turn ends, so the
29
+ * roster may keep reporting its session as running, and the turn it completed
30
+ * is the only verdict it exposes.
31
+ */
32
+ readonly teamMember: boolean;
26
33
  }
27
34
  /** Minimal value copy used by the Host scheduler. */
28
35
  export interface DueScheduleReference {
29
36
  readonly taskId: string;
30
37
  readonly cron: string;
38
+ /** Zone this rule's wall clock is read in. */
39
+ readonly timeZone: string;
31
40
  readonly nextRunAt: number;
32
41
  }
33
42
  /** Derived runtime data for one session-poll pass. */
@@ -116,6 +125,15 @@ export declare class HostTaskLedger {
116
125
  runtimeView(): LedgerRuntimeView;
117
126
  /** Count armed, non-archived schedules without cloning task histories. */
118
127
  armedScheduleCount(): number;
128
+ /**
129
+ * The earliest armed `nextRunAt` strictly after the supplied Host time, or
130
+ * undefined when no schedule is armed in the future. This is the instant the
131
+ * Host arms its native timer at, so the board wakes exactly when work is due
132
+ * instead of polling a fixed heartbeat.
133
+ * @param now - current Host time in ms epoch.
134
+ * @returns the nearest future trigger, or undefined when none is armed.
135
+ */
136
+ nextArmedRunAt(now: number): number | undefined;
119
137
  /** Return value-only references for schedules due at the supplied Host time. */
120
138
  dueSchedules(now: number): DueScheduleReference[];
121
139
  subscribe(listener: () => void): () => void;
@@ -182,13 +200,56 @@ export declare class HostTaskLedger {
182
200
  * (failure dominates, then cancellation).
183
201
  */
184
202
  private settleCascade;
203
+ /**
204
+ * Close every run that is already decided, and fold every lineage whose
205
+ * members are all settled. The Host poll calls this on every tick, so a run
206
+ * can no longer be left in the running column by a settle nobody observed:
207
+ *
208
+ * - A team run is governed by its Lead. Once the Lead's own outcome is
209
+ * recorded, its still-open members are settled with that verdict. A
210
+ * teammate is a durable member of the Team and may never report a turn of
211
+ * its own, and without this the whole lineage waits on it forever.
212
+ * - A cascade parent whose members are all settled is finalized here even
213
+ * when the settle that completed the tree happened in another process, or
214
+ * was interrupted between its child write and its parent write.
215
+ *
216
+ * Idempotent: a run that is already folded is left untouched, so a caller may
217
+ * invoke it on every tick.
218
+ * @param persist - false to leave the document for the caller to commit.
219
+ * @returns whether the document changed.
220
+ */
221
+ finalizeReadyRuns(persist?: boolean): boolean;
222
+ /**
223
+ * The verdict a team run's Lead has already recorded: its own turn outcome
224
+ * once that turn ended, or the folded outcome once its card settled.
225
+ */
226
+ private leadVerdict;
227
+ /**
228
+ * Settle every still-open member of a team run with the Lead's verdict.
229
+ * A teammate that already reported its own outcome keeps it: the ordinary
230
+ * fold still lets a failure dominate.
231
+ * @param leadId - the Lead card's task id (never settled here).
232
+ * @param groupId - the team run's group.
233
+ * @param verdict - the Lead's outcome, inherited by members that never reported.
234
+ * @param error - the Lead's failure text, inherited with a failed verdict.
235
+ * @param now - clock instant (ms epoch).
236
+ * @returns whether the document changed.
237
+ */
238
+ private closeTeamMembers;
185
239
  private repairSchedules;
186
240
  private reconcileInterruptedStarts;
187
241
  /**
188
- * Field-preserving v2 to v3 migration. v3 adds no fields yet, so the
189
- * migration reuses the v3 normalization, but it first proves every task
190
- * row is structurally valid: a v2 document that would silently drop or
191
- * coerce rows fails loudly instead (no quarantined-empty restart).
242
+ * Field-preserving migration of a pre-v4 document. It first proves every task
243
+ * row is structurally valid, so a document that would silently drop or coerce
244
+ * rows fails loudly instead (no quarantined-empty restart), and then reuses
245
+ * the current normalization.
246
+ *
247
+ * v4 adds `ScheduleRule.timeZone`. A rule written before v4 was evaluated in
248
+ * whatever zone the Host process happened to report, so the migration stamps
249
+ * that zone onto every enabled rule: the trigger instant is preserved exactly
250
+ * (the stored `nextRunAt` already encodes the old zone), but the rule stops
251
+ * following a later `TZ` change, which is what made an existing schedule
252
+ * silently move when the Host's zone changed.
192
253
  */
193
254
  private migrateLegacyDocument;
194
255
  private load;
@@ -23,8 +23,18 @@ type ExecutionSessionId = SessionSummary['sessionId'];
23
23
  export interface SessionCommandDispatcher {
24
24
  execute(sessionId: ExecutionSessionId, line: string, signal: AbortSignal): Promise<CommandResult | undefined>;
25
25
  }
26
- export type ExecutionInspection = {
26
+ export type ExecutionInspection =
27
+ /**
28
+ * No verdict yet. `unreadable` separates "the work may still be in progress"
29
+ * from "this session's history could not be read at all": a reader failure is
30
+ * not progress, and the caller decides how long to keep waiting before it
31
+ * reports the execution as undeterminable instead of holding a card open
32
+ * forever.
33
+ */
34
+ {
27
35
  outcome: 'pending';
36
+ unreadable?: true;
37
+ reason?: string;
28
38
  } | {
29
39
  outcome: 'succeeded';
30
40
  } | {
@@ -64,6 +74,17 @@ export interface PromptContext {
64
74
  peers?: readonly PromptPeer[];
65
75
  /** True when those members run as teammates inside this session (Team Lead). */
66
76
  team?: boolean;
77
+ /**
78
+ * Scheduled-run provenance: the instant the cron rule actually fired and the
79
+ * IANA zone its wall clock was read in. Present only for a cron-triggered
80
+ * run, so the model can resolve an unqualified date in the run's own terms
81
+ * instead of guessing from the Host clock (issue #1722).
82
+ */
83
+ schedule?: {
84
+ triggeredAt: number;
85
+ timeZone: string;
86
+ cron: string;
87
+ };
67
88
  }
68
89
  /**
69
90
  * Build the execution prompt for one task.
@@ -72,6 +93,16 @@ export interface PromptContext {
72
93
  * @returns the prompt text.
73
94
  */
74
95
  export declare function promptText(task: TaskRecord, context?: PromptContext): string;
96
+ /**
97
+ * Build the objective the built-in `/goal` command receives. A prompt whose
98
+ * text would be read as a goal operation is prefixed with a neutral label: the
99
+ * instruction stays intact, and the command arms an objective instead of
100
+ * clearing, pausing, or editing an unrelated goal (or refusing for a missing
101
+ * one).
102
+ * @param prompt - the composed execution prompt.
103
+ * @returns the objective text for `/goal <objective>`.
104
+ */
105
+ export declare function goalObjective(prompt: string): string;
75
106
  export declare class HostExecutionRunner {
76
107
  private readonly gateway;
77
108
  private readonly commands?;
@@ -122,6 +153,37 @@ export declare class HostExecutionRunner {
122
153
  * the same permission/model pins before the prompt.
123
154
  */
124
155
  private pinAndPrompt;
156
+ /**
157
+ * Arm dsh's built-in goal on the session. The task prompt is queued FIRST, so
158
+ * the session's first turn carries the instruction verbatim; `/goal
159
+ * <objective>` then turns the same text into a persistent objective, and
160
+ * dsh's goal-round driver keeps starting continuation rounds until the agent
161
+ * marks it complete (see {@link goalVerdict} for how the run settles).
162
+ *
163
+ * Opt-out: a task whose `goalRun` is an explicit false runs one plain turn.
164
+ * A refusal (no command dispatcher, no `/goal` command in this cohort, an
165
+ * objective the command rejects) is reported and the run continues as that
166
+ * plain turn: the task was asked to run, and a missing goal mode must not
167
+ * lose the work.
168
+ */
169
+ private armGoal;
170
+ /**
171
+ * The verdict an armed dsh goal imposes on an otherwise completed turn.
172
+ *
173
+ * A goal run is one session working many automatic continuation rounds:
174
+ * dsh's goal-round driver queues the next round whenever the agent goes
175
+ * idle, so the first completed `turn/end` is not the execution's result.
176
+ * Settling there would leave the card in `done` while the session keeps
177
+ * working, and a scheduled task would fire a second, concurrent session.
178
+ * The session projection is the authority: an `active` goal keeps the
179
+ * execution pending, a `blocked` goal fails it with the recorded reason, and
180
+ * any other phase (complete, paused, or no goal at all) leaves the turn
181
+ * verdict in place.
182
+ *
183
+ * An unreadable projection falls back to that turn verdict: a cohort whose
184
+ * gateway withdrew `session/projections` must not hang every execution.
185
+ */
186
+ private goalVerdict;
125
187
  listRunning(): Promise<{
126
188
  known: true;
127
189
  count: number;
@@ -129,6 +191,17 @@ export declare class HostExecutionRunner {
129
191
  } | {
130
192
  known: false;
131
193
  }>;
132
- /** Resolve an execution outcome from the session list and bounded history pages. */
133
- inspect(sessionId: string, startedAt?: number, sessions?: readonly SessionSummary[]): Promise<ExecutionInspection>;
194
+ /**
195
+ * Resolve an execution outcome from the session list and bounded history pages.
196
+ * @param sessionId - the session the execution runs in.
197
+ * @param startedAt - instant the execution opened; earlier events are not its own.
198
+ * @param sessions - roster from the caller's own poll, when it already has one.
199
+ * @param options.whileRunning - read the first completed turn even while the
200
+ * roster reports the session as running. A durable Agent Teams teammate
201
+ * stays alive after its turn ends, so `running` never clears for it and the
202
+ * turn it completed is the only verdict it will ever expose.
203
+ */
204
+ inspect(sessionId: string, startedAt?: number, sessions?: readonly SessionSummary[], options?: {
205
+ whileRunning?: boolean;
206
+ }): Promise<ExecutionInspection>;
134
207
  }
@@ -31,16 +31,40 @@ export interface TeamSpawnResult {
31
31
  export interface TaskBoardTeamDispatcher {
32
32
  spawn(input: TeamSpawnInput): Promise<TeamSpawnResult>;
33
33
  }
34
+ /**
35
+ * Provenance of one cron-triggered cascade: when the rule fired and the zone
36
+ * its wall clock was read in. Passed into every launched prompt of that run so
37
+ * a scheduled job knows its own clock rather than inferring one.
38
+ */
39
+ export interface ScheduledRunContext {
40
+ triggeredAt: number;
41
+ timeZone: string;
42
+ cron: string;
43
+ }
44
+ /**
45
+ * The native timer face the Host arms through. The cordis `timer` service
46
+ * (dsh-base's own `cordis-plugin-timer` row) provides it: its handles are
47
+ * registered on the owning fiber, so unloading the board clears every armed
48
+ * timer without this service tracking handles by hand. A composition that
49
+ * serves no timer service falls back to the process globals.
50
+ */
51
+ export interface HostTimerFace {
52
+ timeout(callback: () => void, delay: number): () => void;
53
+ interval(callback: () => void, delay: number): () => void;
54
+ }
34
55
  export declare class TaskBoardHostService {
35
56
  readonly ledger: HostTaskLedger;
36
57
  readonly runner: HostExecutionRunner;
37
58
  readonly power: PowerInhibitor;
38
59
  private readonly listeners;
39
- private timers;
40
- private lastScheduleTick;
60
+ /** The one recurring timer: the session-roster poll. */
61
+ private pollTimer;
62
+ /** The armed schedule timer, if a trigger is pending. */
63
+ private scheduleTimer;
64
+ /** The instant the armed schedule timer targets (ms epoch), for resume detection. */
65
+ private scheduleTarget;
41
66
  private disposed;
42
67
  private pollInFlight;
43
- private tickInFlight;
44
68
  private active;
45
69
  /**
46
70
  * Ids the last roster poll saw as present and idle; undefined while the
@@ -49,8 +73,15 @@ export declare class TaskBoardHostService {
49
73
  * conversation instead of prompting into a session it cannot see.
50
74
  */
51
75
  private idleSessionIds;
76
+ /**
77
+ * Consecutive unreadable-history polls per open execution (see
78
+ * {@link noteUnreadableInspection}). Cleared as soon as an inspection
79
+ * resolves, so a transient reader failure never fails a card.
80
+ */
81
+ private readonly unreadablePolls;
52
82
  private preventIdleSleep;
53
83
  private readonly team;
84
+ private readonly timers;
54
85
  private lastPowerJson;
55
86
  private readonly now;
56
87
  constructor(gateway: TypertGateway, options?: {
@@ -62,6 +93,7 @@ export declare class TaskBoardHostService {
62
93
  sessionDefaultPermission?: TaskPermission;
63
94
  maxSubtaskDepth?: number;
64
95
  team?: TaskBoardTeamDispatcher;
96
+ timers?: HostTimerFace;
65
97
  });
66
98
  start(): void;
67
99
  setConfiguration(active: boolean, preventIdleSleep: boolean): void;
@@ -84,7 +116,42 @@ export declare class TaskBoardHostService {
84
116
  private pollSessions;
85
117
  /** Reuse the session list this poll already fetched: one list RPC per tick, not 1 + E. */
86
118
  private reconcileExecutions;
87
- private tickSchedule;
119
+ /**
120
+ * Count one poll whose session history could not be read. A reader failure is
121
+ * not progress: the session is not running (the runner only reads history for
122
+ * one that is idle), so nothing will ever change that verdict. After
123
+ * {@link UNREADABLE_SETTLE_POLLS} consecutive polls the execution is reported
124
+ * failed with the recorded reason, instead of holding its card — and every
125
+ * ancestor of it — in the running column with no way out. The first poll of
126
+ * each streak is logged, so the Host log names the session.
127
+ */
128
+ private noteUnreadableInspection;
129
+ /** Drop the armed schedule timer and forget its target. */
130
+ private clearScheduleTimer;
131
+ /**
132
+ * Boot / resume recovery: skip every occurrence that came due while the
133
+ * board was not running and roll each schedule to its next future target,
134
+ * then arm the timer for the nearest one. Rendering the occurrence is
135
+ * deliberately not attempted: the ACL of a card that fired hours ago is
136
+ * stale, and the board's own recovery contract is "missed triggers are
137
+ * skipped, never replayed".
138
+ */
139
+ private recoverSchedule;
140
+ /**
141
+ * Arm the native timer at the nearest armed future trigger. One timer serves
142
+ * every schedule: the ledger's next target is the only instant the Host has
143
+ * to wake for. A target beyond the platform's timer ceiling re-arms in
144
+ * segments, and a target already past (the wall clock jumped, or the process
145
+ * was suspended) is handled immediately as a recovery.
146
+ */
147
+ private armSchedule;
148
+ /**
149
+ * One armed target became due. A fire landing well past its target is a
150
+ * resume (suspend, throttle, forward clock jump) rather than a normal
151
+ * occurrence, so it takes the recovery path instead of launching a run for a
152
+ * long-stale instant.
153
+ */
154
+ private onScheduleFire;
88
155
  private armedSchedules;
89
156
  /**
90
157
  * Launch one run set. The root goes first and receives the run shape in its
@@ -96,7 +163,12 @@ export declare class TaskBoardHostService {
96
163
  private dispatchRuns;
97
164
  private scheduleLaunch;
98
165
  private schedulePoll;
99
- private scheduleTick;
166
+ /**
167
+ * Re-arm from the ledger's current targets. Callers that just changed a
168
+ * schedule (the host routes, the agent tools) invoke this after the write
169
+ * commits, so a new or edited trigger arms without waiting for the next fire.
170
+ */
171
+ refreshSchedule(): void;
100
172
  private syncPowerReasons;
101
173
  private emit;
102
174
  }
@@ -9,7 +9,7 @@ import type { Context, Volatile } from '@deepseek-ai/cordis';
9
9
  import z from '@deepseek-ai/schemastery';
10
10
  import type { ToolDefinition } from '@deepseek-ai/dsh-tools';
11
11
  import type { LlmRuntime } from '@deepseek-ai/dsh-llm';
12
- import { type TaskBoardTeamDispatcher } from './host-service.ts';
12
+ import { type HostTimerFace, type TaskBoardTeamDispatcher } from './host-service.ts';
13
13
  import { type TaskPermission } from './core/tasks.ts';
14
14
  /** Default environment variable holding the authenticated proxy token. */
15
15
  export declare const DEFAULT_PROXY_TOKEN_ENV = "DSH_TASK_BOARD_PROXY_TOKEN";
@@ -139,6 +139,16 @@ interface AgentToolRegistry {
139
139
  * @returns the registry, or undefined when this deployment serves none.
140
140
  */
141
141
  export declare function resolveToolRegistry(ctx: Context): AgentToolRegistry | undefined;
142
+ /**
143
+ * Resolve the native cordis timer service (dsh-base's own `cordis-plugin-timer`
144
+ * row). The board arms its schedule through it so unloading the row clears
145
+ * every pending timer with the owning fiber, instead of the row tracking raw
146
+ * process handles by hand. A composition that serves no timer service keeps
147
+ * working on the process globals.
148
+ * @param ctx - the plugin context.
149
+ * @returns the timer face, or undefined when this deployment serves none.
150
+ */
151
+ export declare function resolveHostTimers(ctx: Context): HostTimerFace | undefined;
142
152
  /** The subset of the Agent Teams service this plugin drives. */
143
153
  interface AgentTeamsFace {
144
154
  spawnTeammate(caller: unknown, request: {
@@ -4,19 +4,50 @@
4
4
  * the loader accepts a standalone install of the same package side by side;
5
5
  * without this guard the second instance would still re-register the same
6
6
  * webserver routes, tools, settings namespaces, and system-prompt sections
7
- * and fail the boot. mountOnce makes the second host apply a no-op for the
8
- * lifetime of the first instance (the browser half is already deduped by
9
- * package name in the client module host).
7
+ * and fail the boot. mountOnce makes a mount of an already-mounted package a
8
+ * no-op for as long as the first instance lives (the browser half is already
9
+ * deduped by package name in the client module host).
10
+ *
11
+ * A no-op is only safe while the holder is ALIVE. The holder can be disposed
12
+ * long after the refused mount ran its course: the Host reloads a profile by
13
+ * creating the new loader entries before the old ones are torn down (a
14
+ * plugin-manager enable/disable/install write, a settings-driven row reload,
15
+ * HMR), so the new aggregate shell entry mounts its family plugin while the
16
+ * previous entry still owns the name. Dropping that refused mount lost the
17
+ * plugin for good - the previous entry then disposed its own mount, releasing
18
+ * the name with nobody left to take it, and the family row stayed listed as
19
+ * active while its host routes 404ed (the task-board panel showed
20
+ * "board.hostError.notMounted", no degraded record appeared, and only a Host
21
+ * restart recovered it).
22
+ *
23
+ * The refused mount is therefore QUEUED, not dropped, and replayed the moment
24
+ * the holder releases the name - if the waiting fiber is still alive then. The
25
+ * single-instance guarantee is unchanged: exactly one mount is live per
26
+ * package name, and the replay re-enters the guard so a later mount still
27
+ * dedupes against it.
10
28
  *
11
29
  * The registry rides a global symbol so two module instances of the same
12
- * package (npm copy vs repository link) still share one verdict. cordis
13
- * `ctx.effect` runs its callback immediately and treats the callback's
30
+ * package (npm copy vs repository link) still share one verdict. That symbol
31
+ * is a CROSS-REPOSITORY contract, not this file's private state: the four
32
+ * satellite packages (dsh-skins / dsh-pet / dsh-presets /
33
+ * dsh-community-plugins) are separate repositories carrying their own copy of
34
+ * this guard, rebuilt on their own schedule, so the value under `MOUNTED`
35
+ * must keep the shape every published copy reads (a `Set` of package names
36
+ * with `has`/`add`/`delete`). The wait queues this guard added therefore live
37
+ * under their own additive key, and the registry reads back a `Set` even when
38
+ * some other build left a different value there. Changing `MOUNTED`'s shape
39
+ * in place broke that contract once: a satellite's legacy copy created a
40
+ * `Set`, the family's new copy read it as a `Map`, and every family row
41
+ * mounted after it failed with "claims.get is not a function".
42
+ *
43
+ * cordis `ctx.effect` runs its callback immediately and treats the callback's
14
44
  * return value as the fiber disposer, so the unmarker is returned, not run.
15
45
  */
16
46
  /**
17
47
  * Wrap a cordis plugin apply so the package runs at most once per process.
18
- * The first mount registers normally and unmarks when its fiber disposes;
19
- * any later mount of the same package name is a no-op.
48
+ * The first mount registers normally and releases the name when its fiber
49
+ * disposes; a mount refused while that name is held waits for the release and
50
+ * then runs, unless its own fiber disposes first.
20
51
  * @param packageName - npm package identity shared by every install source.
21
52
  * @param fn - the original plugin apply.
22
53
  * @returns an apply of the same shape.
@@ -1,8 +1,10 @@
1
1
  import type { TaskUpdatePatch } from './core/use-cases/task-update.ts';
2
2
  import { type NewTaskInput, type TaskPermission, type TaskRecord, type TaskStatus } from './core/tasks.ts';
3
- export declare const TASK_BOARD_SCHEMA_VERSION: 3;
4
- /** Ledger documents written before v3; loaded once and migrated on startup. */
5
- export declare const TASK_BOARD_LEGACY_SCHEMA_VERSION: 2;
3
+ export declare const TASK_BOARD_SCHEMA_VERSION: 4;
4
+ /** Ledger documents written before v4; loaded once and migrated on startup. */
5
+ export declare const TASK_BOARD_LEGACY_SCHEMA_VERSION: 3;
6
+ /** Ledger documents written before v3; migrated through the v3 normalization too. */
7
+ export declare const TASK_BOARD_OLDER_SCHEMA_VERSION: 2;
6
8
  export declare const TASK_BOARD_API_PREFIX = "/api/task-board";
7
9
  export type PowerPhase = 'disabled' | 'idle' | 'acquiring' | 'active' | 'error' | 'unsupported';
8
10
  export interface TaskBoardPowerSnapshot {
@@ -90,12 +92,16 @@ export type TaskBoardAction = {
90
92
  } | {
91
93
  kind: 'restore';
92
94
  taskId: string;
95
+ } | {
96
+ kind: 'settle';
97
+ taskId: string;
93
98
  } | {
94
99
  kind: 'set-schedule';
95
100
  taskId: string;
96
101
  patch: {
97
102
  enabled?: boolean;
98
103
  cron?: string;
104
+ timeZone?: string | null;
99
105
  };
100
106
  } | {
101
107
  kind: 'run';