@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
package/lib/index.js CHANGED
@@ -16,6 +16,112 @@ const FIELD_RANGES = [
16
16
  [1, 12],
17
17
  [0, 7]
18
18
  ];
19
+ /** IANA `Area/Location`, plus the bare `UTC` the platform reports. */
20
+ const IANA_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/;
21
+ /** Cached formatters: constructing an Intl formatter dominates the cost of a resolve. */
22
+ const FORMATTERS = /* @__PURE__ */ new Map();
23
+ function zoneFormatter(timeZone) {
24
+ let formatter = FORMATTERS.get(timeZone);
25
+ if (formatter === void 0) {
26
+ formatter = new Intl.DateTimeFormat("en-US", {
27
+ timeZone,
28
+ hourCycle: "h23",
29
+ year: "numeric",
30
+ month: "2-digit",
31
+ day: "2-digit",
32
+ hour: "2-digit",
33
+ minute: "2-digit"
34
+ });
35
+ FORMATTERS.set(timeZone, formatter);
36
+ }
37
+ return formatter;
38
+ }
39
+ /**
40
+ * The wall-clock reading of one instant in an explicit zone. Reading through
41
+ * Intl (rather than the process `Date` accessors) is what makes the result
42
+ * independent of the Host's own TZ.
43
+ * @param timeZone - explicit IANA zone.
44
+ * @param epochMs - instant to read.
45
+ * @returns the zone-local calendar and clock fields.
46
+ */
47
+ function partsInZone(timeZone, epochMs) {
48
+ const fields = {};
49
+ for (const part of zoneFormatter(timeZone).formatToParts(new Date(epochMs))) if (part.type !== "literal") fields[part.type] = part.value;
50
+ return {
51
+ year: Number(fields.year),
52
+ month: Number(fields.month),
53
+ day: Number(fields.day),
54
+ hour: Number(fields.hour),
55
+ minute: Number(fields.minute)
56
+ };
57
+ }
58
+ /** The zone's UTC offset in effect at one instant, in milliseconds. */
59
+ function offsetAt(timeZone, epochMs) {
60
+ const parts = partsInZone(timeZone, epochMs);
61
+ return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute) - epochMs;
62
+ }
63
+ /**
64
+ * Resolve a wall clock in an explicit zone to its instant.
65
+ *
66
+ * The offset is probed a day either side of the naive guess so both DST
67
+ * transitions are covered, then every candidate offset whose inverse maps back
68
+ * to the requested wall clock is kept. No survivor means the wall clock does
69
+ * not exist (a spring-forward gap); several means it is ambiguous (a fall-back
70
+ * overlap), and the earliest instant wins so the repeated clock is taken once.
71
+ * @param timeZone - explicit IANA zone.
72
+ * @param year - full calendar year.
73
+ * @param month - 1-12.
74
+ * @param day - day of month.
75
+ * @param hour - 0-23.
76
+ * @param minute - 0-59.
77
+ * @returns the instant in ms epoch, or undefined when the wall clock is skipped.
78
+ */
79
+ function zonedEpoch(timeZone, year, month, day, hour, minute) {
80
+ const naive = Date.UTC(year, month - 1, day, hour, minute);
81
+ const offsets = /* @__PURE__ */ new Set([
82
+ offsetAt(timeZone, naive - 864e5),
83
+ offsetAt(timeZone, naive),
84
+ offsetAt(timeZone, naive + 864e5)
85
+ ]);
86
+ let earliest;
87
+ for (const offset of offsets) {
88
+ const candidate = naive - offset;
89
+ const parts = partsInZone(timeZone, candidate);
90
+ if (parts.year !== year || parts.month !== month || parts.day !== day) continue;
91
+ if (parts.hour !== hour || parts.minute !== minute) continue;
92
+ if (earliest === void 0 || candidate < earliest) earliest = candidate;
93
+ }
94
+ return earliest;
95
+ }
96
+ /**
97
+ * The Host process's IANA zone, used for schedules that store no zone of their
98
+ * own (a ledger written before zones were persisted).
99
+ * @returns a zone name Intl accepts, never empty.
100
+ */
101
+ function resolveHostTimeZone() {
102
+ try {
103
+ const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
104
+ return typeof zone === "string" && zone !== "" ? zone : "UTC";
105
+ } catch {
106
+ return "UTC";
107
+ }
108
+ }
109
+ /**
110
+ * Whether a value names a zone this runtime can actually resolve. A stored
111
+ * zone is trusted only after it round-trips through Intl, so a typo or a zone
112
+ * that this ICU build does not know can never arm a schedule.
113
+ * @param value - candidate zone name.
114
+ * @returns true when the name is usable.
115
+ */
116
+ function isValidTimeZone(value) {
117
+ if (value.trim() !== value || value === "") return false;
118
+ if (value !== "UTC" && !IANA_ZONE.test(value)) return false;
119
+ try {
120
+ return new Intl.DateTimeFormat("en-US", { timeZone: value }).resolvedOptions().timeZone !== void 0;
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
19
125
  /**
20
126
  * Parse a 5-field cron expression.
21
127
  * @returns the match sets, or null when the expression is invalid.
@@ -38,8 +144,8 @@ function parseCron(expr) {
38
144
  days: sets[2],
39
145
  months: sets[3],
40
146
  weekdays,
41
- dayWildcard: fields[2] === "*",
42
- weekdayWildcard: fields[4] === "*"
147
+ dayStarred: fields[2].startsWith("*"),
148
+ weekdayStarred: fields[4].startsWith("*")
43
149
  };
44
150
  }
45
151
  /** Whether the expression parses. */
@@ -47,52 +153,57 @@ function isValidCron(expr) {
47
153
  return parseCron(expr) !== null;
48
154
  }
49
155
  /**
50
- * Compute the next matching instant after `fromMs` (ms epoch), in local time,
51
- * at minute granularity, strictly greater than `fromMs`. Returns the ms epoch
52
- * of the matching minute's start, or undefined when the calendar constraint
53
- * can never match (for example `0 0 30 2 *`). The five-year horizon includes
54
- * a full leap cycle, so a valid February 29 schedule remains reachable from
55
- * every non-leap year.
156
+ * Compute the next matching instant after `fromMs` strictly greater than it,
157
+ * interpreting the expression's wall clock in `timeZone` (the Host zone when
158
+ * omitted). Returns the ms epoch of the matching minute's start, or undefined
159
+ * when the calendar constraint can never match (for example `0 0 30 2 *`).
160
+ * The five-year horizon includes a full leap cycle, so a valid February 29
161
+ * schedule remains reachable from every non-leap year.
56
162
  *
57
163
  * Walks candidate year/month/day/hour/minute values straight from the parsed
58
164
  * field sets instead of scanning every minute: a sparse expression such as
59
165
  * `0 0 29 2 *` used to iterate ~1.5M wall-clock minutes before reaching the
60
- * next leap day. Wall-clock field construction + the final `matches` re-check
61
- * preserve the old minute scan's DST semantics exactly (nonexistent spring
62
- * minutes normalize forward and the repeated fall-back hour is never visited).
166
+ * next leap day. A candidate wall clock that the zone skips (spring-forward
167
+ * gap) is passed over, which is what makes the gap semantics fall out of the
168
+ * candidate walk rather than needing a separate rule.
169
+ * @param expr - 5-field cron expression.
170
+ * @param fromMs - exclusive lower bound, ms epoch.
171
+ * @param timeZone - explicit IANA zone; defaults to the Host zone.
172
+ * @returns the next trigger instant, or undefined when unreachable.
63
173
  */
64
- function nextRunAtMs(expr, fromMs) {
174
+ function nextRunAtMs(expr, fromMs, timeZone) {
65
175
  const schedule = parseCron(expr);
66
176
  if (schedule === null) return void 0;
67
177
  if (!hasPossibleCalendarDay(schedule)) return void 0;
68
- const from = new Date(fromMs);
178
+ const zone = timeZone ?? resolveHostTimeZone();
179
+ const start = partsInZone(zone, fromMs);
69
180
  const limitMs = fromMs + 5 * 366 * 24 * 60 * 60 * 1e3;
70
181
  const sortedMinutes = [...schedule.minutes].sort((a, b) => a - b);
71
182
  const sortedHours = [...schedule.hours].sort((a, b) => a - b);
72
183
  const sortedMonths = [...schedule.months].sort((a, b) => a - b);
73
- let year = from.getFullYear();
74
- let month = from.getMonth() + 1;
75
- let day = from.getDate();
76
- let hour = from.getHours();
77
- let minute = from.getMinutes() + 1;
78
- while (new Date(year, month - 1, 1, 0, 0, 0, 0).getTime() <= limitMs) {
184
+ let year = start.year;
185
+ let month = start.month;
186
+ let day = start.day;
187
+ let hour = start.hour;
188
+ let minute = start.minute + 1;
189
+ while (Date.UTC(year, month - 1, 1) <= limitMs + 864e5) {
79
190
  for (const candidateMonth of sortedMonths) {
80
191
  if (candidateMonth < month) continue;
81
- const daysInMonth = new Date(year, candidateMonth, 0).getDate();
192
+ const daysInMonth = new Date(Date.UTC(year, candidateMonth, 0)).getUTCDate();
82
193
  const dayStart = candidateMonth === month ? day : 1;
83
194
  for (let candidateDay = dayStart; candidateDay <= daysInMonth; candidateDay += 1) {
84
- if (!dayCandidate(schedule, new Date(year, candidateMonth - 1, candidateDay, 0, 0, 0, 0))) continue;
195
+ if (!dayCandidate(schedule, year, candidateMonth, candidateDay)) continue;
85
196
  const hourStart = candidateMonth === month && candidateDay === day ? hour : 0;
86
197
  for (const candidateHour of sortedHours) {
87
198
  if (candidateHour < hourStart) continue;
88
199
  const minuteStart = candidateMonth === month && candidateDay === day && candidateHour === hour ? minute : 0;
89
200
  for (const candidateMinute of sortedMinutes) {
90
201
  if (candidateMinute < minuteStart) continue;
91
- const candidate = new Date(year, candidateMonth - 1, candidateDay, candidateHour, candidateMinute, 0, 0);
92
- const time = candidate.getTime();
202
+ const time = zonedEpoch(zone, year, candidateMonth, candidateDay, candidateHour, candidateMinute);
203
+ if (time === void 0) continue;
93
204
  if (time <= fromMs) continue;
94
205
  if (time > limitMs) return void 0;
95
- if (matches(schedule, candidate)) return time;
206
+ return time;
96
207
  }
97
208
  }
98
209
  }
@@ -104,17 +215,20 @@ function nextRunAtMs(expr, fromMs) {
104
215
  minute = 0;
105
216
  }
106
217
  }
107
- /** Day/weekday OR gate shared by {@link matches} and the candidate scan. */
108
- function dayCandidate(schedule, date) {
109
- const dayMatches = schedule.days.has(date.getDate());
110
- const weekdayMatches = schedule.weekdays.has(date.getDay());
111
- if (schedule.dayWildcard) return weekdayMatches;
112
- if (schedule.weekdayWildcard) return dayMatches;
113
- return dayMatches || weekdayMatches;
218
+ /**
219
+ * The Vixie day gate shared by the candidate walk. Both fields restricted is
220
+ * OR; any other combination is AND, because a starred field matches every
221
+ * value and so cannot widen the result.
222
+ */
223
+ function dayCandidate(schedule, year, month, day) {
224
+ const dayMatches = schedule.days.has(day);
225
+ const weekdayMatches = schedule.weekdays.has(new Date(Date.UTC(year, month - 1, day)).getUTCDay());
226
+ if (!schedule.dayStarred && !schedule.weekdayStarred) return dayMatches || weekdayMatches;
227
+ return dayMatches && weekdayMatches;
114
228
  }
115
229
  /** Reject impossible month/day pairs without spending the multi-year scan. */
116
230
  function hasPossibleCalendarDay(schedule) {
117
- if (schedule.dayWildcard || !schedule.weekdayWildcard) return true;
231
+ if (schedule.dayStarred || !schedule.weekdayStarred) return true;
118
232
  const maximumDay = /* @__PURE__ */ new Map([
119
233
  [1, 31],
120
234
  [2, 29],
@@ -165,13 +279,6 @@ function parseField(field, min, max, out) {
165
279
  }
166
280
  return true;
167
281
  }
168
- /** Day/weekday OR semantics: a restricted day field alone gates, and vice versa. */
169
- function matches(schedule, date) {
170
- if (!schedule.minutes.has(date.getMinutes())) return false;
171
- if (!schedule.hours.has(date.getHours())) return false;
172
- if (!schedule.months.has(date.getMonth() + 1)) return false;
173
- return dayCandidate(schedule, date);
174
- }
175
282
  function isDigits(value) {
176
283
  return /^\d+$/.test(value);
177
284
  }
@@ -379,6 +486,7 @@ function createTask(input, now, id) {
379
486
  model: normalizeTargetId(input.model),
380
487
  reuseSession: input.reuseSession === true ? true : void 0,
381
488
  teamRun: input.teamRun === true ? true : void 0,
489
+ goalRun: input.goalRun === false ? false : void 0,
382
490
  ...input.freeze === void 0 ? {} : { freeze: freezeOf(input.freeze, now) },
383
491
  ...input.handover === void 0 ? {} : { handover: {
384
492
  ...input.handover,
@@ -406,11 +514,14 @@ function withSchedule(task, patch, now) {
406
514
  const schedule = {
407
515
  enabled: current?.enabled ?? false,
408
516
  cron: current?.cron ?? "",
517
+ ...current?.timeZone === void 0 ? {} : { timeZone: current.timeZone },
409
518
  nextRunAt: current?.nextRunAt,
410
519
  lastTriggeredAt: current?.lastTriggeredAt
411
520
  };
412
521
  if ("enabled" in patch) schedule.enabled = patch.enabled ?? false;
413
522
  if ("cron" in patch) schedule.cron = patch.cron ?? "";
523
+ if ("timeZone" in patch) if (patch.timeZone === void 0) delete schedule.timeZone;
524
+ else schedule.timeZone = patch.timeZone;
414
525
  if ("nextRunAt" in patch) schedule.nextRunAt = patch.nextRunAt;
415
526
  if ("lastTriggeredAt" in patch) schedule.lastTriggeredAt = patch.lastTriggeredAt;
416
527
  return {
@@ -501,8 +612,8 @@ function indexOf(tasks) {
501
612
  * under a larger limit survives that limit being lowered, and restore and
502
613
  * inheritance must still see all of it.
503
614
  */
504
- function ancestorChain(tasks, task) {
505
- const index = indexOf(tasks);
615
+ function ancestorChain(tasks, task, lineage) {
616
+ const index = lineage?.byId ?? indexOf(tasks);
506
617
  const chain = [];
507
618
  const seen = /* @__PURE__ */ new Set([task.id]);
508
619
  let current = task.parentId === void 0 ? void 0 : index.get(task.parentId);
@@ -515,29 +626,33 @@ function ancestorChain(tasks, task) {
515
626
  return chain;
516
627
  }
517
628
  /** Root-to-task depth: a root task is 0, its subtask 1, and so on. */
518
- function taskDepth(tasks, id) {
519
- const task = indexOf(tasks).get(id);
520
- return task === void 0 ? 0 : ancestorChain(tasks, task).length;
629
+ function taskDepth(tasks, id, lineage) {
630
+ const task = (lineage?.byId ?? indexOf(tasks)).get(id);
631
+ return task === void 0 ? 0 : ancestorChain(tasks, task, lineage).length;
521
632
  }
522
633
  /** Direct subtasks of a task, in ledger order. */
523
- function directSubtasks(tasks, id) {
634
+ function directSubtasks(tasks, id, lineage) {
635
+ if (lineage !== void 0) return [...lineage.childrenByParent.get(id) ?? []];
524
636
  return tasks.filter((task) => task.parentId === id);
525
637
  }
526
638
  /**
527
639
  * Descendants of a task in breadth-first order, bounded by maxSubtaskDepth
528
640
  * total depth. The visited set keeps a malformed ledger from looping.
529
641
  */
530
- function descendantTasks(tasks, id, maxSubtaskDepth) {
642
+ function descendantTasks(tasks, id, maxSubtaskDepth, lineage) {
531
643
  const visited = /* @__PURE__ */ new Set([id]);
532
644
  const found = [];
533
645
  let frontier = [id];
534
646
  for (let depth = 1; depth <= maxSubtaskDepth && frontier.length > 0; depth += 1) {
535
647
  const next = [];
536
- for (const parentId of frontier) for (const task of tasks) {
537
- if (task.parentId !== parentId || visited.has(task.id)) continue;
538
- visited.add(task.id);
539
- found.push(task);
540
- next.push(task.id);
648
+ for (const parentId of frontier) {
649
+ const children = lineage === void 0 ? tasks.filter((task) => task.parentId === parentId) : lineage.childrenByParent.get(parentId) ?? [];
650
+ for (const task of children) {
651
+ if (visited.has(task.id)) continue;
652
+ visited.add(task.id);
653
+ found.push(task);
654
+ next.push(task.id);
655
+ }
541
656
  }
542
657
  frontier = next;
543
658
  }
@@ -548,13 +663,13 @@ function descendantTasks(tasks, id, maxSubtaskDepth) {
548
663
  * The visited set makes the walk terminate on a cyclic hand-edited ledger
549
664
  * instead of recursing until the stack overflows.
550
665
  */
551
- function subtreeHeight(tasks, id, visited = /* @__PURE__ */ new Set()) {
666
+ function subtreeHeight(tasks, id, visited = /* @__PURE__ */ new Set(), lineage) {
552
667
  if (visited.has(id)) return 0;
553
668
  const seen = /* @__PURE__ */ new Set([...visited, id]);
554
- const children = directSubtasks(tasks, id);
669
+ const children = directSubtasks(tasks, id, lineage);
555
670
  if (children.length === 0) return 0;
556
671
  let height = 0;
557
- for (const child of children) height = Math.max(height, 1 + subtreeHeight(tasks, child.id, seen));
672
+ for (const child of children) height = Math.max(height, 1 + subtreeHeight(tasks, child.id, seen, lineage));
558
673
  return Math.min(height, 3);
559
674
  }
560
675
  /**
@@ -564,8 +679,8 @@ function subtreeHeight(tasks, id, visited = /* @__PURE__ */ new Set()) {
564
679
  * and the resulting depth (parent depth + 1 + the child own subtree height)
565
680
  * must stay within maxSubtaskDepth.
566
681
  */
567
- function checkParentLink(tasks, childId, parentId, maxSubtaskDepth) {
568
- if (tasks.find((task) => task.id === childId) === void 0) return {
682
+ function checkParentLink(tasks, childId, parentId, maxSubtaskDepth, lineage) {
683
+ if ((lineage === void 0 ? tasks.find((task) => task.id === childId) : lineage.byId.get(childId)) === void 0) return {
569
684
  ok: false,
570
685
  reason: "unknown-task"
571
686
  };
@@ -574,7 +689,7 @@ function checkParentLink(tasks, childId, parentId, maxSubtaskDepth) {
574
689
  ok: false,
575
690
  reason: "self-parent"
576
691
  };
577
- const parent = tasks.find((task) => task.id === parentId);
692
+ const parent = lineage === void 0 ? tasks.find((task) => task.id === parentId) : lineage.byId.get(parentId);
578
693
  if (parent === void 0) return {
579
694
  ok: false,
580
695
  reason: "unknown-parent"
@@ -583,11 +698,11 @@ function checkParentLink(tasks, childId, parentId, maxSubtaskDepth) {
583
698
  ok: false,
584
699
  reason: "archived-parent"
585
700
  };
586
- if (descendantTasks(tasks, childId, 3).some((task) => task.id === parentId)) return {
701
+ if (descendantTasks(tasks, childId, 3, lineage).some((task) => task.id === parentId)) return {
587
702
  ok: false,
588
703
  reason: "cycle"
589
704
  };
590
- if (taskDepth(tasks, parentId) + 1 + subtreeHeight(tasks, childId) > maxSubtaskDepth) return {
705
+ if (taskDepth(tasks, parentId, lineage) + 1 + subtreeHeight(tasks, childId, /* @__PURE__ */ new Set(), lineage) > maxSubtaskDepth) return {
591
706
  ok: false,
592
707
  reason: "depth-exceeded"
593
708
  };
@@ -671,18 +786,44 @@ function effectiveTaskPermission(task) {
671
786
  */
672
787
  /**
673
788
  * The immutable teammate name for one team-mode member. Teammate names are
674
- * lower-kebab-case and must stay unique for the lifetime of the Team, so the
675
- * run-group token is appended: two runs of the same tree never collide. A title
676
- * with no ASCII word characters (a CJK title, for example) keeps a stable
677
- * generic prefix and is still distinguished by the token.
789
+ * lower-kebab-case and must stay unique for the lifetime of the Team: the
790
+ * run-group token separates two runs of the same tree, and the member
791
+ * discriminator separates two members of one run that reduce to the same slug.
792
+ *
793
+ * The discriminator is not cosmetic. A title with no ASCII word characters (a
794
+ * CJK title, for example) carries no slug at all, so every such title of one
795
+ * run group collides on the generic prefix; Agent Teams answers every member
796
+ * after the first with `teammate name "..." was already used in this Team`,
797
+ * and those subtasks never start. Two ASCII titles that share a slug
798
+ * (`AI 强度预测` next to `AI 配比优化`) collide the same way.
678
799
  * @param title - the member task's title.
679
800
  * @param token - the run group id (the task id when no group is recorded).
801
+ * @param memberId - the member task's id, which makes the name unique in the run.
680
802
  * @returns the teammate name handed to the Agent Teams service.
681
803
  */
682
- function teammateName(title, token) {
804
+ function teammateName(title, token, memberId) {
683
805
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32).replace(/-+$/g, "");
684
806
  const suffix = token.replace(/[^a-z0-9]/gi, "").slice(0, 8).toLowerCase();
685
- return `${slug === "" ? "subtask" : slug}-${suffix === "" ? "run" : suffix}`;
807
+ const member = memberId === void 0 || memberId === "" ? void 0 : stableTag(memberId);
808
+ return [
809
+ slug === "" ? "subtask" : slug,
810
+ member,
811
+ suffix === "" ? "run" : suffix
812
+ ].filter((part) => part !== void 0).join("-");
813
+ }
814
+ /**
815
+ * A stable 4-hex tag for one member id. The name is derived twice for the same
816
+ * member — once for the Lead's prompt and once for the spawn request — so the
817
+ * tag must be a pure function of the id, never of a clock or a counter. FNV-1a
818
+ * keeps it dependency-free in `core/`, which both halves compile.
819
+ */
820
+ function stableTag(value) {
821
+ let hash = 2166136261;
822
+ for (let index = 0; index < value.length; index += 1) {
823
+ hash ^= value.charCodeAt(index);
824
+ hash = Math.imul(hash, 16777619) >>> 0;
825
+ }
826
+ return hash.toString(16).padStart(8, "0").slice(0, 4);
686
827
  }
687
828
  function resolveExecutionTargets(task, tasks, maxSubtaskDepth = 3) {
688
829
  if (task.parentId === void 0) return task;
@@ -910,6 +1051,7 @@ function isTaskRecordShape(value) {
910
1051
  if (record.mode !== void 0 && typeof record.mode !== "string") return false;
911
1052
  if (record.permission !== void 0 && typeof record.permission !== "string") return false;
912
1053
  if (record.reuseSession !== void 0 && typeof record.reuseSession !== "boolean") return false;
1054
+ if (record.goalRun !== void 0 && typeof record.goalRun !== "boolean") return false;
913
1055
  if (!Array.isArray(record.executions)) return false;
914
1056
  for (const execution of record.executions) {
915
1057
  if (typeof execution !== "object" || execution === null) return false;
@@ -944,9 +1086,11 @@ function normalizeSchedule(schedule) {
944
1086
  const rule = schedule;
945
1087
  if (typeof rule.cron !== "string") return void 0;
946
1088
  if (rule.cron.trim() === "" || !isValidCron(rule.cron)) return void 0;
1089
+ const timeZone = typeof rule.timeZone === "string" && isValidTimeZone(rule.timeZone) ? rule.timeZone : void 0;
947
1090
  return {
948
1091
  enabled: rule.enabled === true,
949
1092
  cron: rule.cron,
1093
+ ...timeZone === void 0 ? {} : { timeZone },
950
1094
  nextRunAt: typeof rule.nextRunAt === "number" ? rule.nextRunAt : void 0,
951
1095
  lastTriggeredAt: typeof rule.lastTriggeredAt === "number" ? rule.lastTriggeredAt : void 0
952
1096
  };
@@ -1031,6 +1175,7 @@ function parseLedger(raw) {
1031
1175
  task.archivedAt = typeof row.archivedAt === "number" && Number.isFinite(row.archivedAt) ? row.archivedAt : void 0;
1032
1176
  task.permission = isTaskPermission(row.permission) ? row.permission : void 0;
1033
1177
  task.reuseSession = row.reuseSession === true ? true : void 0;
1178
+ task.goalRun = row.goalRun === false ? false : void 0;
1034
1179
  task.freeze = normalizeFreeze(row.freeze);
1035
1180
  task.handover = normalizeHandover(row.handover);
1036
1181
  task.tags = normalizeTags(row.tags);
@@ -1145,8 +1290,9 @@ function applyRestoreTask(tasks, id, now, maxSubtaskDepth = 1) {
1145
1290
  * @param now - clock instant (ms epoch).
1146
1291
  * @param id - minted task id.
1147
1292
  * @param maxSubtaskDepth - deployment subtask depth limit.
1293
+ * @param hostTimeZone - zone a rule that stores no zone of its own follows.
1148
1294
  */
1149
- function applyCreateTask(tasks, input, now, id, maxSubtaskDepth = 1) {
1295
+ function applyCreateTask(tasks, input, now, id, maxSubtaskDepth = 1, hostTimeZone = resolveHostTimeZone()) {
1150
1296
  if (input.title.trim() === "") return {
1151
1297
  task: void 0,
1152
1298
  tasks,
@@ -1181,10 +1327,18 @@ function applyCreateTask(tasks, input, now, id, maxSubtaskDepth = 1) {
1181
1327
  const requested = input.schedule;
1182
1328
  if (requested?.enabled === true && requested.cron.trim() !== "" && isValidCron(requested.cron)) {
1183
1329
  const cron = requested.cron.trim();
1184
- task = withSchedule(task, {
1330
+ const requestedZone = requested.timeZone;
1331
+ if (requestedZone !== void 0 && !isValidTimeZone(requestedZone)) return {
1332
+ task: void 0,
1333
+ tasks,
1334
+ error: "invalid schedule time zone"
1335
+ };
1336
+ const nextRunAt = nextRunAtMs(cron, now, requestedZone ?? hostTimeZone);
1337
+ if (nextRunAt !== void 0) task = withSchedule(task, {
1185
1338
  enabled: true,
1186
1339
  cron,
1187
- nextRunAt: nextRunAtMs(cron, now)
1340
+ timeZone: requestedZone,
1341
+ nextRunAt
1188
1342
  }, now);
1189
1343
  }
1190
1344
  return {
@@ -1222,15 +1376,17 @@ function applyDeleteTask(tasks, selectedTaskId, id) {
1222
1376
  * the core cron parser (schedule.ts) and the withSchedule transition.
1223
1377
  */
1224
1378
  /**
1225
- * Set an on-board task's schedule rule. A blank or invalid cron, or an
1226
- * archived task, is rejected (state untouched); an enabled rule computes the
1227
- * next run instant immediately, a disabled one carries no next-run instant.
1379
+ * Set an on-board task's schedule rule. A blank or invalid cron, an unknown or
1380
+ * archived task, or an unusable zone is rejected (state untouched); an enabled
1381
+ * rule computes the next run instant immediately in the rule's own zone, a
1382
+ * disabled one carries no next-run instant.
1228
1383
  * @param tasks - current ledger.
1229
1384
  * @param id - the task to schedule.
1230
1385
  * @param patch - rule fields to change (absent fields keep their current value).
1231
1386
  * @param now - clock instant (ms epoch).
1387
+ * @param hostTimeZone - zone a rule with no stored zone follows.
1232
1388
  */
1233
- function applySetSchedule(tasks, id, patch, now) {
1389
+ function applySetSchedule(tasks, id, patch, now, hostTimeZone) {
1234
1390
  const task = tasks.find((candidate) => candidate.id === id);
1235
1391
  if (task === void 0 || task.archivedAt !== void 0) return {
1236
1392
  tasks,
@@ -1242,8 +1398,13 @@ function applySetSchedule(tasks, id, patch, now) {
1242
1398
  tasks,
1243
1399
  applied: false
1244
1400
  };
1401
+ const requestedZone = patch.timeZone === void 0 ? current?.timeZone : patch.timeZone ?? void 0;
1402
+ if (requestedZone !== void 0 && !isValidTimeZone(requestedZone)) return {
1403
+ tasks,
1404
+ applied: false
1405
+ };
1245
1406
  const enabled = patch.enabled ?? current?.enabled ?? false;
1246
- const nextRunAt = enabled ? nextRunAtMs(cron, now) : void 0;
1407
+ const nextRunAt = enabled ? nextRunAtMs(cron, now, requestedZone ?? hostTimeZone) : void 0;
1247
1408
  if (enabled && nextRunAt === void 0) return {
1248
1409
  tasks,
1249
1410
  applied: false
@@ -1252,6 +1413,7 @@ function applySetSchedule(tasks, id, patch, now) {
1252
1413
  tasks: tasks.map((candidate) => candidate.id === id ? withSchedule(candidate, {
1253
1414
  enabled,
1254
1415
  cron,
1416
+ timeZone: requestedZone ?? void 0,
1255
1417
  nextRunAt
1256
1418
  }, now) : candidate),
1257
1419
  applied: true
@@ -1411,6 +1573,7 @@ function applyUpdateTask(tasks, id, patch, now) {
1411
1573
  if ("permission" in patch && patch.permission !== void 0 && patch.permission !== task.permission || "handover" in patch) next.permissionConfirmedAt = void 0;
1412
1574
  if ("reuseSession" in patch) next.reuseSession = patch.reuseSession === true ? true : void 0;
1413
1575
  if ("teamRun" in patch) next.teamRun = patch.teamRun === true ? true : void 0;
1576
+ if ("goalRun" in patch) next.goalRun = patch.goalRun === false ? false : void 0;
1414
1577
  if (workspaceId !== void 0 || "workspaceId" in patch) next.workspaceId = workspaceId;
1415
1578
  if (mode !== void 0 || "mode" in patch) next.mode = mode;
1416
1579
  if (permission !== void 0 || "permission" in patch) next.permission = permission;
@@ -1464,6 +1627,7 @@ function validImportedKnownFields(value) {
1464
1627
  const schedule = record(value.schedule);
1465
1628
  if (schedule === void 0 || typeof schedule.enabled !== "boolean" || typeof schedule.cron !== "string") return false;
1466
1629
  if (!optionalFiniteNumber(schedule.nextRunAt) || !optionalFiniteNumber(schedule.lastTriggeredAt)) return false;
1630
+ if (schedule.timeZone !== void 0 && (typeof schedule.timeZone !== "string" || !isValidTimeZone(schedule.timeZone))) return false;
1467
1631
  }
1468
1632
  if (value.executions !== void 0) {
1469
1633
  if (!Array.isArray(value.executions)) return false;
@@ -1511,6 +1675,7 @@ function importedTask(value) {
1511
1675
  ...task.schedule === void 0 ? {} : { schedule: {
1512
1676
  enabled: task.schedule.enabled,
1513
1677
  cron: task.schedule.cron,
1678
+ ...task.schedule.timeZone === void 0 ? {} : { timeZone: task.schedule.timeZone },
1514
1679
  nextRunAt: task.schedule.nextRunAt,
1515
1680
  lastTriggeredAt: task.schedule.lastTriggeredAt
1516
1681
  } },
@@ -1519,6 +1684,7 @@ function importedTask(value) {
1519
1684
  ...task.mode === void 0 ? {} : { mode: task.mode },
1520
1685
  ...task.permission === void 0 ? {} : { permission: task.permission },
1521
1686
  ...task.reuseSession === void 0 ? {} : { reuseSession: task.reuseSession },
1687
+ ...task.goalRun === void 0 ? {} : { goalRun: task.goalRun },
1522
1688
  ...task.archivedAt === void 0 ? {} : { archivedAt: task.archivedAt },
1523
1689
  ...task.freeze === void 0 ? {} : { freeze: task.freeze },
1524
1690
  ...task.handover === void 0 ? {} : { handover: task.handover },
@@ -1565,6 +1731,7 @@ function createInput(value) {
1565
1731
  "model",
1566
1732
  "reuseSession",
1567
1733
  "teamRun",
1734
+ "goalRun",
1568
1735
  "tags"
1569
1736
  ])) return false;
1570
1737
  if (input.parentId !== void 0 && (typeof input.parentId !== "string" || input.parentId.trim() === "")) return false;
@@ -1572,14 +1739,20 @@ function createInput(value) {
1572
1739
  if (!optionalString(input.workspaceId) || !optionalString(input.mode) || !optionalString(input.model)) return false;
1573
1740
  if (input.reuseSession !== void 0 && typeof input.reuseSession !== "boolean") return false;
1574
1741
  if (input.teamRun !== void 0 && typeof input.teamRun !== "boolean") return false;
1742
+ if (input.goalRun !== void 0 && typeof input.goalRun !== "boolean") return false;
1575
1743
  if (input.permission !== void 0 && !isTaskPermission(input.permission)) return false;
1576
1744
  if (input.tags !== void 0 && !isTaskTagList(input.tags)) return false;
1577
1745
  if (input.freeze !== void 0 && freezePayload(input.freeze) === void 0) return false;
1578
1746
  if (input.handover !== void 0 && handoverPayload(input.handover) === void 0) return false;
1579
1747
  if (input.schedule !== void 0) {
1580
1748
  const schedule = record(input.schedule);
1581
- if (schedule === void 0 || !exactKeys(schedule, ["enabled", "cron"])) return false;
1749
+ if (schedule === void 0 || !exactKeys(schedule, [
1750
+ "enabled",
1751
+ "cron",
1752
+ "timeZone"
1753
+ ])) return false;
1582
1754
  if (typeof schedule.enabled !== "boolean" || typeof schedule.cron !== "string") return false;
1755
+ if (schedule.timeZone !== void 0 && (typeof schedule.timeZone !== "string" || !isValidTimeZone(schedule.timeZone))) return false;
1583
1756
  }
1584
1757
  return true;
1585
1758
  }
@@ -1597,10 +1770,12 @@ function updatePatch(value) {
1597
1770
  "model",
1598
1771
  "reuseSession",
1599
1772
  "teamRun",
1773
+ "goalRun",
1600
1774
  "tags"
1601
1775
  ])) return false;
1602
1776
  if (patch.reuseSession !== void 0 && patch.reuseSession !== null && typeof patch.reuseSession !== "boolean") return false;
1603
1777
  if (patch.teamRun !== void 0 && patch.teamRun !== null && typeof patch.teamRun !== "boolean") return false;
1778
+ if (patch.goalRun !== void 0 && patch.goalRun !== null && typeof patch.goalRun !== "boolean") return false;
1604
1779
  for (const key of [
1605
1780
  "title",
1606
1781
  "description",
@@ -1616,7 +1791,11 @@ function updatePatch(value) {
1616
1791
  }
1617
1792
  function schedulePatch(value) {
1618
1793
  const patch = record(value);
1619
- return patch !== void 0 && exactKeys(patch, ["enabled", "cron"]) && (patch.enabled === void 0 || typeof patch.enabled === "boolean") && (patch.cron === void 0 || typeof patch.cron === "string");
1794
+ return patch !== void 0 && exactKeys(patch, [
1795
+ "enabled",
1796
+ "cron",
1797
+ "timeZone"
1798
+ ]) && (patch.enabled === void 0 || typeof patch.enabled === "boolean") && (patch.cron === void 0 || typeof patch.cron === "string") && (patch.timeZone === void 0 || patch.timeZone === null || typeof patch.timeZone === "string" && isValidTimeZone(patch.timeZone));
1620
1799
  }
1621
1800
  function parseActionEnvelope(value) {
1622
1801
  const parsed = parseEnvelopeAction(value);
@@ -1752,6 +1931,7 @@ function parseEnvelopeAction(value) {
1752
1931
  case "delete":
1753
1932
  case "archive":
1754
1933
  case "restore":
1934
+ case "settle":
1755
1935
  case "run":
1756
1936
  case "rerun":
1757
1937
  if (!exactKeys(action, ["kind", "taskId"])) return void 0;
@@ -1773,7 +1953,11 @@ function bindingRefusalMessage(refusal, sessionDefault, surface) {
1773
1953
  }
1774
1954
  const MAX_REQUEST_CACHE = 256;
1775
1955
  function timeZone() {
1776
- return Intl.DateTimeFormat().resolvedOptions().timeZone || "local";
1956
+ return resolveHostTimeZone();
1957
+ }
1958
+ /** The zone a schedule is evaluated in: its own, or the Host zone when it stores none. */
1959
+ function scheduleZone(schedule) {
1960
+ return schedule.timeZone ?? timeZone();
1777
1961
  }
1778
1962
  function cloneTasks(tasks) {
1779
1963
  return JSON.parse(JSON.stringify(tasks));
@@ -2044,6 +2228,7 @@ function parseHostTasks(values) {
2044
2228
  schedule: {
2045
2229
  enabled: false,
2046
2230
  cron: schedule.cron,
2231
+ ...typeof schedule.timeZone === "string" && isValidTimeZone(schedule.timeZone) ? { timeZone: schedule.timeZone } : {},
2047
2232
  nextRunAt: void 0,
2048
2233
  lastTriggeredAt: typeof schedule.lastTriggeredAt === "number" && Number.isFinite(schedule.lastTriggeredAt) ? schedule.lastTriggeredAt : void 0
2049
2234
  }
@@ -2084,6 +2269,7 @@ var HostTaskLedger = class {
2084
2269
  for (const request of this.document.recentRequests) this.requestCache.set(request.requestId, { fingerprint: request.fingerprint });
2085
2270
  this.repairSchedules(true);
2086
2271
  this.reconcileInterruptedStarts();
2272
+ this.finalizeReadyRuns(false);
2087
2273
  this.commit(false);
2088
2274
  } catch (error) {
2089
2275
  this.dispose();
@@ -2137,6 +2323,11 @@ var HostTaskLedger = class {
2137
2323
  */
2138
2324
  runtimeView() {
2139
2325
  let armedSchedules = 0;
2326
+ const teamGroups = /* @__PURE__ */ new Set();
2327
+ for (const task of this.document.tasks) {
2328
+ if (task.teamRun !== true) continue;
2329
+ for (const execution of task.executions) if (execution.runGroupId !== void 0) teamGroups.add(execution.runGroupId);
2330
+ }
2140
2331
  const openExecutions = [];
2141
2332
  for (const task of this.document.tasks) {
2142
2333
  if (task.archivedAt === void 0 && task.schedule?.enabled === true) armedSchedules += 1;
@@ -2147,7 +2338,8 @@ var HostTaskLedger = class {
2147
2338
  taskId: task.id,
2148
2339
  executionId: execution.id,
2149
2340
  sessionId: execution.sessionId,
2150
- startedAt: execution.startedAt
2341
+ startedAt: execution.startedAt,
2342
+ teamMember: task.teamRun !== true && execution.runGroupId !== void 0 && teamGroups.has(execution.runGroupId)
2151
2343
  });
2152
2344
  }
2153
2345
  }
@@ -2162,6 +2354,25 @@ var HostTaskLedger = class {
2162
2354
  for (const task of this.document.tasks) if (task.archivedAt === void 0 && task.schedule?.enabled === true) count += 1;
2163
2355
  return count;
2164
2356
  }
2357
+ /**
2358
+ * The earliest armed `nextRunAt` strictly after the supplied Host time, or
2359
+ * undefined when no schedule is armed in the future. This is the instant the
2360
+ * Host arms its native timer at, so the board wakes exactly when work is due
2361
+ * instead of polling a fixed heartbeat.
2362
+ * @param now - current Host time in ms epoch.
2363
+ * @returns the nearest future trigger, or undefined when none is armed.
2364
+ */
2365
+ nextArmedRunAt(now) {
2366
+ let nearest;
2367
+ for (const task of this.document.tasks) {
2368
+ if (task.archivedAt !== void 0) continue;
2369
+ const schedule = task.schedule;
2370
+ if (schedule === void 0 || !schedule.enabled || schedule.nextRunAt === void 0) continue;
2371
+ if (schedule.nextRunAt <= now) continue;
2372
+ if (nearest === void 0 || schedule.nextRunAt < nearest) nearest = schedule.nextRunAt;
2373
+ }
2374
+ return nearest;
2375
+ }
2165
2376
  /** Return value-only references for schedules due at the supplied Host time. */
2166
2377
  dueSchedules(now) {
2167
2378
  const due = [];
@@ -2172,6 +2383,7 @@ var HostTaskLedger = class {
2172
2383
  due.push({
2173
2384
  taskId: task.id,
2174
2385
  cron: schedule.cron,
2386
+ timeZone: scheduleZone(schedule),
2175
2387
  nextRunAt: schedule.nextRunAt
2176
2388
  });
2177
2389
  }
@@ -2244,7 +2456,7 @@ var HostTaskLedger = class {
2244
2456
  ...task,
2245
2457
  schedule: {
2246
2458
  ...schedule,
2247
- nextRunAt: nextRunAtMs(schedule.cron, now)
2459
+ nextRunAt: nextRunAtMs(schedule.cron, now, scheduleZone(schedule))
2248
2460
  },
2249
2461
  updatedAt: now
2250
2462
  };
@@ -2297,15 +2509,17 @@ var HostTaskLedger = class {
2297
2509
  });
2298
2510
  changed = true;
2299
2511
  }
2512
+ if (task.teamRun === true && this.closeTeamMembers(taskId, groupId, execution.ownResult ?? outcome, execution.ownError ?? error, now)) changed = true;
2300
2513
  if (this.settleCascade(taskId, groupId, now)) changed = true;
2301
2514
  if (changed) this.commit();
2302
2515
  }
2303
2516
  attachSession(taskId, executionId, sessionId) {
2304
2517
  const now = this.now();
2305
- this.document.tasks = this.document.tasks.map((task) => task.id !== taskId ? task : {
2306
- ...task,
2518
+ if (this.document.tasks.find((item) => item.id === taskId)?.executions.find((entry) => entry.id === executionId)?.endedAt !== void 0) return;
2519
+ this.document.tasks = this.document.tasks.map((item) => item.id !== taskId ? item : {
2520
+ ...item,
2307
2521
  updatedAt: now,
2308
- executions: task.executions.map((entry) => entry.id === executionId ? {
2522
+ executions: item.executions.map((entry) => entry.id === executionId ? {
2309
2523
  ...entry,
2310
2524
  sessionId
2311
2525
  } : entry)
@@ -2328,11 +2542,12 @@ var HostTaskLedger = class {
2328
2542
  this.document.scheduler.error = invalidScheduleIds.length === 0 ? void 0 : `invalid cron disabled for task(s): ${invalidScheduleIds.join(", ")}`;
2329
2543
  this.repairSchedules(true, false);
2330
2544
  this.reconcileInterruptedStarts(false);
2545
+ this.finalizeReadyRuns(false);
2331
2546
  break;
2332
2547
  }
2333
2548
  case "create": {
2334
2549
  if (this.document.tasks.some((task) => task.id === action.id)) throw new Error("task id already exists");
2335
- if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron) || nextRunAtMs(action.input.schedule.cron, now) === void 0)) throw new Error("invalid schedule");
2550
+ if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron) || !isValidTimeZone(action.input.schedule.timeZone ?? timeZone()) || nextRunAtMs(action.input.schedule.cron, now, action.input.schedule.timeZone ?? timeZone()) === void 0)) throw new Error("invalid schedule");
2336
2551
  const input = action.input.freeze === void 0 || initiator === void 0 || initiator === "" ? action.input : {
2337
2552
  ...action.input,
2338
2553
  freeze: {
@@ -2340,7 +2555,7 @@ var HostTaskLedger = class {
2340
2555
  frozenBy: initiator
2341
2556
  }
2342
2557
  };
2343
- const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth);
2558
+ const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth, timeZone());
2344
2559
  if (result.task === void 0) throw new Error(result.error ?? "invalid task");
2345
2560
  this.document.tasks = [...result.tasks];
2346
2561
  break;
@@ -2391,6 +2606,19 @@ var HostTaskLedger = class {
2391
2606
  this.document.tasks = [...result.tasks];
2392
2607
  break;
2393
2608
  }
2609
+ case "settle": {
2610
+ const task = this.document.tasks.find((item) => item.id === action.taskId);
2611
+ if (task === void 0) throw new Error("task not found");
2612
+ if (task.archivedAt !== void 0) throw new Error("archived task is read-only");
2613
+ if (!task.executions.some((entry) => entry.endedAt === void 0)) throw new Error("task has no open execution");
2614
+ const reason = "execution closed manually by " + (initiator === void 0 || initiator === "" ? "the operator" : initiator) + ": no verdict was recorded";
2615
+ for (const member of cascadeTargets(this.document.tasks, action.taskId, this.maxSubtaskDepth)) for (const execution of member.executions) {
2616
+ if (execution.endedAt !== void 0) continue;
2617
+ this.document.tasks = this.document.tasks.map((item) => item.id !== member.id ? item : settleExecution(item, execution.id, "cancelled", now, reason));
2618
+ }
2619
+ this.finalizeReadyRuns(false);
2620
+ break;
2621
+ }
2394
2622
  case "restore": {
2395
2623
  if (this.subtreeHasOpenExecution(action.taskId)) throw new Error("running task cannot be restored");
2396
2624
  const result = applyRestoreTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth);
@@ -2411,7 +2639,7 @@ var HostTaskLedger = class {
2411
2639
  }
2412
2640
  case "set-schedule": {
2413
2641
  if (this.document.tasks.find((task) => task.id === action.taskId)?.archivedAt !== void 0) throw new Error("archived task is read-only");
2414
- const result = applySetSchedule(this.document.tasks, action.taskId, action.patch, now);
2642
+ const result = applySetSchedule(this.document.tasks, action.taskId, action.patch, now, timeZone());
2415
2643
  if (!result.applied) throw new Error("invalid schedule");
2416
2644
  this.document.tasks = [...result.tasks];
2417
2645
  break;
@@ -2555,6 +2783,86 @@ var HostTaskLedger = class {
2555
2783
  }
2556
2784
  return changed;
2557
2785
  }
2786
+ /**
2787
+ * Close every run that is already decided, and fold every lineage whose
2788
+ * members are all settled. The Host poll calls this on every tick, so a run
2789
+ * can no longer be left in the running column by a settle nobody observed:
2790
+ *
2791
+ * - A team run is governed by its Lead. Once the Lead's own outcome is
2792
+ * recorded, its still-open members are settled with that verdict. A
2793
+ * teammate is a durable member of the Team and may never report a turn of
2794
+ * its own, and without this the whole lineage waits on it forever.
2795
+ * - A cascade parent whose members are all settled is finalized here even
2796
+ * when the settle that completed the tree happened in another process, or
2797
+ * was interrupted between its child write and its parent write.
2798
+ *
2799
+ * Idempotent: a run that is already folded is left untouched, so a caller may
2800
+ * invoke it on every tick.
2801
+ * @param persist - false to leave the document for the caller to commit.
2802
+ * @returns whether the document changed.
2803
+ */
2804
+ finalizeReadyRuns(persist = true) {
2805
+ const now = this.now();
2806
+ let changed = false;
2807
+ const groups = /* @__PURE__ */ new Set();
2808
+ for (const task of this.document.tasks) for (const execution of task.executions) if (execution.endedAt === void 0 && execution.runGroupId !== void 0) groups.add(execution.runGroupId);
2809
+ for (const groupId of groups) {
2810
+ const lead = this.document.tasks.find((task) => task.teamRun === true && task.executions.some((entry) => entry.runGroupId === groupId));
2811
+ const verdict = lead === void 0 ? void 0 : this.leadVerdict(lead, groupId);
2812
+ if (lead !== void 0 && verdict !== void 0 && this.closeTeamMembers(lead.id, groupId, verdict.result, verdict.error, now)) changed = true;
2813
+ for (const task of this.document.tasks) if (this.settleCascade(task.id, groupId, now)) changed = true;
2814
+ }
2815
+ if (changed && persist) this.commit();
2816
+ return changed;
2817
+ }
2818
+ /**
2819
+ * The verdict a team run's Lead has already recorded: its own turn outcome
2820
+ * once that turn ended, or the folded outcome once its card settled.
2821
+ */
2822
+ leadVerdict(lead, groupId) {
2823
+ const execution = lead.executions.find((entry) => entry.runGroupId === groupId);
2824
+ const result = execution?.ownResult ?? execution?.result;
2825
+ if (result === void 0) return void 0;
2826
+ return {
2827
+ result,
2828
+ error: execution?.ownError ?? execution?.error
2829
+ };
2830
+ }
2831
+ /**
2832
+ * Settle every still-open member of a team run with the Lead's verdict.
2833
+ * A teammate that already reported its own outcome keeps it: the ordinary
2834
+ * fold still lets a failure dominate.
2835
+ * @param leadId - the Lead card's task id (never settled here).
2836
+ * @param groupId - the team run's group.
2837
+ * @param verdict - the Lead's outcome, inherited by members that never reported.
2838
+ * @param error - the Lead's failure text, inherited with a failed verdict.
2839
+ * @param now - clock instant (ms epoch).
2840
+ * @returns whether the document changed.
2841
+ */
2842
+ closeTeamMembers(leadId, groupId, verdict, error, now) {
2843
+ const members = [];
2844
+ let recorded = false;
2845
+ this.document.tasks = this.document.tasks.map((task) => {
2846
+ if (task.id === leadId) return task;
2847
+ const execution = openGroupExecution(task, groupId);
2848
+ if (execution === void 0) return task;
2849
+ members.push(task.id);
2850
+ if (execution.ownResult !== void 0) return task;
2851
+ recorded = true;
2852
+ return {
2853
+ ...task,
2854
+ updatedAt: now,
2855
+ executions: task.executions.map((entry) => entry.id === execution.id ? {
2856
+ ...entry,
2857
+ ownResult: verdict,
2858
+ ownError: error
2859
+ } : entry)
2860
+ };
2861
+ });
2862
+ let changed = recorded;
2863
+ for (const memberId of members) if (this.settleCascade(memberId, groupId, now)) changed = true;
2864
+ return changed;
2865
+ }
2558
2866
  repairSchedules(skipPast, persist = true) {
2559
2867
  const now = this.now();
2560
2868
  let changed = false;
@@ -2562,7 +2870,7 @@ var HostTaskLedger = class {
2562
2870
  const schedule = task.schedule;
2563
2871
  if (schedule === void 0 || !schedule.enabled) return task;
2564
2872
  if (!skipPast && schedule.nextRunAt !== void 0) return task;
2565
- const next = nextRunAtMs(schedule.cron, now);
2873
+ const next = nextRunAtMs(schedule.cron, now, scheduleZone(schedule));
2566
2874
  if (next === void 0) {
2567
2875
  changed = true;
2568
2876
  this.document.scheduler.error = `invalid cron disabled for task: ${task.id}`;
@@ -2612,14 +2920,36 @@ var HostTaskLedger = class {
2612
2920
  if (changed && persist) this.commit();
2613
2921
  }
2614
2922
  /**
2615
- * Field-preserving v2 to v3 migration. v3 adds no fields yet, so the
2616
- * migration reuses the v3 normalization, but it first proves every task
2617
- * row is structurally valid: a v2 document that would silently drop or
2618
- * coerce rows fails loudly instead (no quarantined-empty restart).
2923
+ * Field-preserving migration of a pre-v4 document. It first proves every task
2924
+ * row is structurally valid, so a document that would silently drop or coerce
2925
+ * rows fails loudly instead (no quarantined-empty restart), and then reuses
2926
+ * the current normalization.
2927
+ *
2928
+ * v4 adds `ScheduleRule.timeZone`. A rule written before v4 was evaluated in
2929
+ * whatever zone the Host process happened to report, so the migration stamps
2930
+ * that zone onto every enabled rule: the trigger instant is preserved exactly
2931
+ * (the stored `nextRunAt` already encodes the old zone), but the rule stops
2932
+ * following a later `TZ` change, which is what made an existing schedule
2933
+ * silently move when the Host's zone changed.
2619
2934
  */
2620
2935
  migrateLegacyDocument(parsed) {
2621
- if (!Array.isArray(parsed.tasks) || !parsed.tasks.every((row) => isTaskRecord(row))) throw new Error("v2 document contains structurally invalid task rows");
2622
- return this.normalizeDocument(parsed);
2936
+ if (!Array.isArray(parsed.tasks) || !parsed.tasks.every((row) => isTaskRecord(row))) throw new Error(`v${String(parsed.schemaVersion)} document contains structurally invalid task rows`);
2937
+ const rows = parsed.tasks.map((value) => {
2938
+ const schedule = value.schedule;
2939
+ if (typeof schedule !== "object" || schedule === null) return value;
2940
+ if (typeof schedule.timeZone === "string") return value;
2941
+ return {
2942
+ ...value,
2943
+ schedule: {
2944
+ ...schedule,
2945
+ timeZone: timeZone()
2946
+ }
2947
+ };
2948
+ });
2949
+ return this.normalizeDocument({
2950
+ ...parsed,
2951
+ tasks: rows
2952
+ });
2623
2953
  }
2624
2954
  load(dir) {
2625
2955
  const existed = existsSync(this.file);
@@ -2629,13 +2959,13 @@ var HostTaskLedger = class {
2629
2959
  } catch (error) {
2630
2960
  return this.recoverCorrupt(dir, existed, error);
2631
2961
  }
2632
- if (parsed.schemaVersion === 2) try {
2962
+ if (parsed.schemaVersion === 3 || parsed.schemaVersion === 2) try {
2633
2963
  return this.migrateLegacyDocument(parsed);
2634
2964
  } catch (error) {
2635
- throw new Error(`ledger v2 to v3 migration failed; original file kept at ${this.file}: ${error instanceof Error ? error.message : String(error)}`);
2965
+ throw new Error(`ledger v${String(parsed.schemaVersion)} to v4 migration failed; original file kept at ${this.file}: ${error instanceof Error ? error.message : String(error)}`);
2636
2966
  }
2637
2967
  try {
2638
- if (parsed.schemaVersion !== 3 || !Array.isArray(parsed.tasks)) throw new Error("unsupported ledger schema");
2968
+ if (parsed.schemaVersion !== 4 || !Array.isArray(parsed.tasks)) throw new Error("unsupported ledger schema");
2639
2969
  return this.normalizeDocument(parsed);
2640
2970
  } catch (error) {
2641
2971
  return this.recoverCorrupt(dir, existed, error);
@@ -2657,7 +2987,7 @@ var HostTaskLedger = class {
2657
2987
  const sidecarLastTickAt = this.readSchedulerSidecar();
2658
2988
  const lastTickAt = sidecarLastTickAt === void 0 || documentLastTickAt !== void 0 && documentLastTickAt >= sidecarLastTickAt ? documentLastTickAt : sidecarLastTickAt;
2659
2989
  return {
2660
- schemaVersion: 3,
2990
+ schemaVersion: 4,
2661
2991
  revision: Number.isSafeInteger(parsed.revision) && parsed.revision >= 0 ? parsed.revision : 0,
2662
2992
  tasks,
2663
2993
  scheduler: {
@@ -2683,7 +3013,7 @@ var HostTaskLedger = class {
2683
3013
  if (existed) renameSync(this.file, `${this.file}.corrupt-${this.now()}-${process.pid}-${crypto.randomUUID()}`);
2684
3014
  mkdirSync(dir, { recursive: true });
2685
3015
  return {
2686
- schemaVersion: 3,
3016
+ schemaVersion: 4,
2687
3017
  revision: 0,
2688
3018
  tasks: [],
2689
3019
  scheduler: {
@@ -2875,6 +3205,16 @@ function isDefinitionWithdrawn(error) {
2875
3205
  }
2876
3206
  const SERVICE_UNAVAILABLE_ATTEMPTS = 5;
2877
3207
  const SERVICE_UNAVAILABLE_BACKOFF_MS = 2e3;
3208
+ /**
3209
+ * Largest number of history pages one inspection walks back. A session whose
3210
+ * window is wider than this cannot be resolved from its log, which the caller
3211
+ * treats as an unreadable history rather than as pending work.
3212
+ */
3213
+ const HISTORY_PAGE_LIMIT = 100;
3214
+ /** One-line text of a gateway or stream failure, for a decideable reason. */
3215
+ function failureText(error) {
3216
+ return error instanceof Error ? error.message : String(error);
3217
+ }
2878
3218
  function delay(ms) {
2879
3219
  return new Promise((resolve) => {
2880
3220
  setTimeout(resolve, ms);
@@ -2913,13 +3253,25 @@ function peerPromptPreamble(context) {
2913
3253
  const peers = context.peers ?? [];
2914
3254
  if (peers.length === 0) return void 0;
2915
3255
  if (context.team === true) {
2916
- const lines = peers.map((peer) => `- ${escapeProvenanceDelimiter(peer.title)}(teammate: ${peer.name ?? teammateName(peer.title, peer.id)})`);
3256
+ const lines = peers.map((peer) => `- ${escapeProvenanceDelimiter(peer.title)}(teammate: ${peer.name ?? teammateName(peer.title, peer.id, peer.id)})`);
2917
3257
  return `本任务是 Agent Team 的 Lead:本次运行不额外开启独立会话,以下 ${peers.length} 个子任务成员已在本会话中作为 teammate 启动。用 list_agents / send_message / wait_agent 协调它们,用任务看板工具读写它们在看板上的卡片:\n${lines.join("\n")}`;
2918
3258
  }
2919
3259
  const lines = peers.map((peer) => `- ${escapeProvenanceDelimiter(peer.title)}(任务 ${peer.id})`);
2920
3260
  return `本次运行同时并发开启 ${peers.length} 个独立 DSH 会话执行下列子任务成员(可用 task_board_* 工具查看它们的进度):\n${lines.join("\n")}`;
2921
3261
  }
2922
3262
  /**
3263
+ * The scheduled-run section: this execution was triggered by the card's cron
3264
+ * rule rather than by a person. It states the firing instant, the rule's zone,
3265
+ * and the rule text, so the run's own clock is unambiguous — the board's
3266
+ * scheduler fires on a wall clock in a specific IANA zone, which is not
3267
+ * necessarily the zone of whatever session the run lands in.
3268
+ */
3269
+ function schedulePromptPreamble(context) {
3270
+ const schedule = context.schedule;
3271
+ if (schedule === void 0) return void 0;
3272
+ return `本次执行由任务看板的定时规则自动触发:触发时间 ${new Date(schedule.triggeredAt).toISOString()}(UTC),规则时区 ${schedule.timeZone},cron 表达式 ${schedule.cron}。需要判断「今天」「现在」或计算时间窗口时,以该时区的触发时间为准。`;
3273
+ }
3274
+ /**
2923
3275
  * Build the execution prompt for one task.
2924
3276
  * @param task - the task being launched.
2925
3277
  * @param context - the run's other members, for the execution-shape section.
@@ -2932,6 +3284,7 @@ function promptText(task, context = {}) {
2932
3284
  const preambles = [
2933
3285
  tagPromptPreamble(task),
2934
3286
  handoverPreamble,
3287
+ schedulePromptPreamble(context),
2935
3288
  peerPromptPreamble(context)
2936
3289
  ].filter((part) => part !== void 0);
2937
3290
  const preamble = preambles.length === 0 ? void 0 : preambles.join("\n\n");
@@ -2942,6 +3295,25 @@ function promptText(task, context = {}) {
2942
3295
  return preamble === void 0 ? declaration : `${preamble}\n\n${declaration}`;
2943
3296
  }
2944
3297
  /**
3298
+ * The parts of an objective the built-in `/goal` command reads as an operation
3299
+ * rather than as an objective: an exact `clear`/`pause`/`resume`/`edit`
3300
+ * (case-insensitive) and a leading `edit <objective>`.
3301
+ */
3302
+ const GOAL_COMMAND_KEYWORD = /^(?:clear|pause|resume|edit)$|^edit\s/i;
3303
+ /**
3304
+ * Build the objective the built-in `/goal` command receives. A prompt whose
3305
+ * text would be read as a goal operation is prefixed with a neutral label: the
3306
+ * instruction stays intact, and the command arms an objective instead of
3307
+ * clearing, pausing, or editing an unrelated goal (or refusing for a missing
3308
+ * one).
3309
+ * @param prompt - the composed execution prompt.
3310
+ * @returns the objective text for `/goal <objective>`.
3311
+ */
3312
+ function goalObjective(prompt) {
3313
+ const text = prompt.trim();
3314
+ return GOAL_COMMAND_KEYWORD.test(text) ? `Goal: ${text}` : text;
3315
+ }
3316
+ /**
2945
3317
  * Build the tag section of the execution prompt (issue #1521). Only tags with
2946
3318
  * a non-blank `promptPrefix` contribute; a task whose tags are all bare names
2947
3319
  * (or which has no tags at all) yields undefined and the prompt is byte-for-byte
@@ -3131,6 +3503,73 @@ var HostExecutionRunner = class {
3131
3503
  text: promptText(task, context)
3132
3504
  }]
3133
3505
  });
3506
+ await this.armGoal(sessionId, task, context);
3507
+ }
3508
+ /**
3509
+ * Arm dsh's built-in goal on the session. The task prompt is queued FIRST, so
3510
+ * the session's first turn carries the instruction verbatim; `/goal
3511
+ * <objective>` then turns the same text into a persistent objective, and
3512
+ * dsh's goal-round driver keeps starting continuation rounds until the agent
3513
+ * marks it complete (see {@link goalVerdict} for how the run settles).
3514
+ *
3515
+ * Opt-out: a task whose `goalRun` is an explicit false runs one plain turn.
3516
+ * A refusal (no command dispatcher, no `/goal` command in this cohort, an
3517
+ * objective the command rejects) is reported and the run continues as that
3518
+ * plain turn: the task was asked to run, and a missing goal mode must not
3519
+ * lose the work.
3520
+ */
3521
+ async armGoal(sessionId, task, context) {
3522
+ if (task.goalRun === false) return;
3523
+ if (this.commands === void 0) {
3524
+ console.warn("[dsh-task-board] no command dispatcher is available; task " + task.id + " runs without /goal");
3525
+ return;
3526
+ }
3527
+ try {
3528
+ const command = await this.commands.execute(sessionId, "/goal " + goalObjective(promptText(task, context)), AbortSignal.timeout(3e4));
3529
+ if (command === void 0) {
3530
+ console.warn("[dsh-task-board] /goal was not acknowledged for session " + sessionId + "; the run continues as a plain turn");
3531
+ return;
3532
+ }
3533
+ if (command.kind !== "success") console.warn("[dsh-task-board] /goal was refused for session " + sessionId + ": " + (command.text ?? "no reason reported") + "; the run continues as a plain turn");
3534
+ } catch (error) {
3535
+ console.warn("[dsh-task-board] could not arm /goal on session " + sessionId + "; the run continues as a plain turn", error);
3536
+ }
3537
+ }
3538
+ /**
3539
+ * The verdict an armed dsh goal imposes on an otherwise completed turn.
3540
+ *
3541
+ * A goal run is one session working many automatic continuation rounds:
3542
+ * dsh's goal-round driver queues the next round whenever the agent goes
3543
+ * idle, so the first completed `turn/end` is not the execution's result.
3544
+ * Settling there would leave the card in `done` while the session keeps
3545
+ * working, and a scheduled task would fire a second, concurrent session.
3546
+ * The session projection is the authority: an `active` goal keeps the
3547
+ * execution pending, a `blocked` goal fails it with the recorded reason, and
3548
+ * any other phase (complete, paused, or no goal at all) leaves the turn
3549
+ * verdict in place.
3550
+ *
3551
+ * An unreadable projection falls back to that turn verdict: a cohort whose
3552
+ * gateway withdrew `session/projections` must not hang every execution.
3553
+ */
3554
+ async goalVerdict(sessionId) {
3555
+ let projections;
3556
+ try {
3557
+ projections = await this.invoke("session", "projections", { sessionId });
3558
+ } catch (error) {
3559
+ console.warn("[dsh-task-board] session/projections failed while reading the goal phase; settling from the turn result", error);
3560
+ return;
3561
+ }
3562
+ const goal = projections?.values?.goal;
3563
+ if (goal === void 0 || goal === null) return void 0;
3564
+ const view = goal.goal;
3565
+ if (view?.phase === "active") return { outcome: "pending" };
3566
+ if (view?.phase === "blocked") {
3567
+ const message = view.blockedReason?.message;
3568
+ return {
3569
+ outcome: "failed",
3570
+ error: "goal is blocked: " + (typeof message === "string" && message !== "" ? message : "no reason recorded")
3571
+ };
3572
+ }
3134
3573
  }
3135
3574
  async listRunning() {
3136
3575
  for (let attempt = 1;; attempt++) try {
@@ -3162,8 +3601,17 @@ var HostExecutionRunner = class {
3162
3601
  await delay(this.unavailableBackoffMs);
3163
3602
  }
3164
3603
  }
3165
- /** Resolve an execution outcome from the session list and bounded history pages. */
3166
- async inspect(sessionId, startedAt = 0, sessions) {
3604
+ /**
3605
+ * Resolve an execution outcome from the session list and bounded history pages.
3606
+ * @param sessionId - the session the execution runs in.
3607
+ * @param startedAt - instant the execution opened; earlier events are not its own.
3608
+ * @param sessions - roster from the caller's own poll, when it already has one.
3609
+ * @param options.whileRunning - read the first completed turn even while the
3610
+ * roster reports the session as running. A durable Agent Teams teammate
3611
+ * stays alive after its turn ends, so `running` never clears for it and the
3612
+ * turn it completed is the only verdict it will ever expose.
3613
+ */
3614
+ async inspect(sessionId, startedAt = 0, sessions, options = {}) {
3167
3615
  let items;
3168
3616
  if (sessions !== void 0) items = sessions;
3169
3617
  else {
@@ -3195,7 +3643,7 @@ var HostExecutionRunner = class {
3195
3643
  error: "execution session no longer exists"
3196
3644
  };
3197
3645
  }
3198
- if (summary.running) return { outcome: "pending" };
3646
+ if (summary.running && options.whileRunning !== true) return { outcome: "pending" };
3199
3647
  let opening;
3200
3648
  try {
3201
3649
  const iterator = (await this.stream("session", "follow", {
@@ -3205,7 +3653,11 @@ var HostExecutionRunner = class {
3205
3653
  const next = await iterator.next();
3206
3654
  if (typeof iterator.return === "function") await iterator.return();
3207
3655
  const follow = next.done === true ? void 0 : next.value;
3208
- if (follow === void 0 || follow.type !== "snapshot" || typeof follow.cursor !== "number" || follow.records === void 0 || typeof follow.hasMore !== "boolean") return { outcome: "pending" };
3656
+ if (follow === void 0 || follow.type !== "snapshot" || typeof follow.cursor !== "number" || follow.records === void 0 || typeof follow.hasMore !== "boolean") return {
3657
+ outcome: "pending",
3658
+ unreadable: true,
3659
+ reason: "session/follow returned no opening frame"
3660
+ };
3209
3661
  opening = {
3210
3662
  cursor: follow.cursor,
3211
3663
  records: follow.records,
@@ -3213,7 +3665,11 @@ var HostExecutionRunner = class {
3213
3665
  };
3214
3666
  } catch (error) {
3215
3667
  console.warn("[dsh-task-board] session/follow failed during execution inspection; keeping the outcome pending", error);
3216
- return { outcome: "pending" };
3668
+ return {
3669
+ outcome: "pending",
3670
+ unreadable: true,
3671
+ reason: "session/follow failed: " + failureText(error)
3672
+ };
3217
3673
  }
3218
3674
  const openingEvents = opening.records.map((record) => ({ event: recordEvent(record) }));
3219
3675
  const newestSeq = openingEvents.reduce((newest, entry) => newest === void 0 ? entry.event.seq : Math.max(newest, entry.event.seq), void 0);
@@ -3221,7 +3677,7 @@ var HostExecutionRunner = class {
3221
3677
  const events = [...openingEvents];
3222
3678
  let beforeSeq;
3223
3679
  let reachedExecutionBoundary = !opening.hasMore;
3224
- for (let page = 0; page < 100 && !reachedExecutionBoundary; page += 1) {
3680
+ for (let page = 0; page < HISTORY_PAGE_LIMIT && !reachedExecutionBoundary; page += 1) {
3225
3681
  let history;
3226
3682
  try {
3227
3683
  history = await this.invoke("session", "page", {
@@ -3232,7 +3688,11 @@ var HostExecutionRunner = class {
3232
3688
  });
3233
3689
  } catch (error) {
3234
3690
  console.warn("[dsh-task-board] session/page failed during execution inspection; keeping the outcome pending", error);
3235
- return { outcome: "pending" };
3691
+ return {
3692
+ outcome: "pending",
3693
+ unreadable: true,
3694
+ reason: "session/page failed: " + failureText(error)
3695
+ };
3236
3696
  }
3237
3697
  const pageEntries = pageEvents(history);
3238
3698
  events.push(...pageEntries);
@@ -3245,7 +3705,11 @@ var HostExecutionRunner = class {
3245
3705
  if (oldestSeq === void 0 || oldestSeq === beforeSeq) return { outcome: "pending" };
3246
3706
  beforeSeq = oldestSeq;
3247
3707
  }
3248
- if (!reachedExecutionBoundary) return { outcome: "pending" };
3708
+ if (!reachedExecutionBoundary) return {
3709
+ outcome: "pending",
3710
+ unreadable: true,
3711
+ reason: "history scan did not reach the execution start within 100 pages"
3712
+ };
3249
3713
  const turnEnd = events.filter((entry) => entry.event.type === "turn/end" && (startedAt <= 0 || entry.event.time >= startedAt)).sort((a, b) => a.event.seq - b.event.seq)[0];
3250
3714
  if (turnEnd === void 0) {
3251
3715
  if (newestSeq !== void 0) this.scanMemos.set(sessionId, newestSeq);
@@ -3253,7 +3717,7 @@ var HostExecutionRunner = class {
3253
3717
  }
3254
3718
  this.scanMemos.delete(sessionId);
3255
3719
  const reason = nonCompletedTurnEnd(turnEnd.event.data);
3256
- if (reason === void 0) return { outcome: "succeeded" };
3720
+ if (reason === void 0) return await this.goalVerdict(sessionId) ?? { outcome: "succeeded" };
3257
3721
  return reason === "error" ? {
3258
3722
  outcome: "failed",
3259
3723
  error: "agent turn ended with an error"
@@ -3567,19 +4031,64 @@ var PowerInhibitor = class {
3567
4031
  };
3568
4032
  //#endregion
3569
4033
  //#region src/host-service.ts
4034
+ /** Session-roster poll cadence — the one recurring Host timer this service still holds. */
3570
4035
  const SESSION_POLL_MS = 5e3;
3571
- const SCHEDULE_TICK_MS = 3e4;
3572
- const RESUME_GAP_MS = 45e3;
4036
+ /**
4037
+ * How late an armed schedule fire may be before it counts as a resume rather
4038
+ * than a normal occurrence. The schedule timer is armed AT the next due
4039
+ * instant, so landing this far past its target means the Host was suspended,
4040
+ * the process throttled, or the wall clock jumped forward — the same condition
4041
+ * the old fixed 30 s heartbeat detected through its own gap threshold.
4042
+ */
4043
+ const RECOVERY_TOLERANCE_MS = 6e4;
4044
+ /** Largest delay a Node timer represents without clamping; longer targets re-arm in segments. */
4045
+ const MAX_TIMER_DELAY_MS = 2147483647;
4046
+ /**
4047
+ * Consecutive polls that may report one execution's session history as
4048
+ * unreadable before it is reported failed. The roster poll runs every 5 s, so
4049
+ * this is two minutes of a session that is NOT running: no turn is executing
4050
+ * there and its history cannot be read, which means no verdict will ever
4051
+ * arrive. Reporting that as a failure keeps a card — and every ancestor of it —
4052
+ * out of the running column, which nothing else can rescue.
4053
+ */
4054
+ const UNREADABLE_SETTLE_POLLS = 24;
4055
+ /**
4056
+ * Actions that can move an armed trigger. Only these re-arm the native timer;
4057
+ * an unrelated card edit leaves the pending fire untouched.
4058
+ */
4059
+ const SCHEDULE_WRITE_ACTIONS = /* @__PURE__ */ new Set([
4060
+ "set-schedule",
4061
+ "delete",
4062
+ "archive"
4063
+ ]);
4064
+ /** Process-global fallback used when the deployment serves no cordis timer service. */
4065
+ const PROCESS_TIMERS = {
4066
+ timeout(callback, delay) {
4067
+ const handle = setTimeout(callback, delay);
4068
+ return () => {
4069
+ clearTimeout(handle);
4070
+ };
4071
+ },
4072
+ interval(callback, delay) {
4073
+ const handle = setInterval(callback, delay);
4074
+ return () => {
4075
+ clearInterval(handle);
4076
+ };
4077
+ }
4078
+ };
3573
4079
  var TaskBoardHostService = class {
3574
4080
  ledger;
3575
4081
  runner;
3576
4082
  power;
3577
4083
  listeners = /* @__PURE__ */ new Set();
3578
- timers = [];
3579
- lastScheduleTick;
4084
+ /** The one recurring timer: the session-roster poll. */
4085
+ pollTimer;
4086
+ /** The armed schedule timer, if a trigger is pending. */
4087
+ scheduleTimer;
4088
+ /** The instant the armed schedule timer targets (ms epoch), for resume detection. */
4089
+ scheduleTarget;
3580
4090
  disposed = false;
3581
4091
  pollInFlight = false;
3582
- tickInFlight = false;
3583
4092
  active = true;
3584
4093
  /**
3585
4094
  * Ids the last roster poll saw as present and idle; undefined while the
@@ -3588,8 +4097,15 @@ var TaskBoardHostService = class {
3588
4097
  * conversation instead of prompting into a session it cannot see.
3589
4098
  */
3590
4099
  idleSessionIds;
4100
+ /**
4101
+ * Consecutive unreadable-history polls per open execution (see
4102
+ * {@link noteUnreadableInspection}). Cleared as soon as an inspection
4103
+ * resolves, so a transient reader failure never fails a card.
4104
+ */
4105
+ unreadablePolls = /* @__PURE__ */ new Map();
3591
4106
  preventIdleSleep = false;
3592
4107
  team;
4108
+ timers;
3593
4109
  lastPowerJson = "";
3594
4110
  now;
3595
4111
  constructor(gateway, options = {}) {
@@ -3599,6 +4115,7 @@ var TaskBoardHostService = class {
3599
4115
  });
3600
4116
  this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry);
3601
4117
  this.team = options.team;
4118
+ this.timers = options.timers ?? PROCESS_TIMERS;
3602
4119
  this.power = options.power ?? new PowerInhibitor();
3603
4120
  this.now = options.now ?? Date.now;
3604
4121
  installStreamErrorGuards();
@@ -3614,16 +4131,13 @@ var TaskBoardHostService = class {
3614
4131
  });
3615
4132
  }
3616
4133
  start() {
3617
- if (this.disposed || this.timers.length > 0) return;
4134
+ if (this.disposed || this.pollTimer !== void 0) return;
3618
4135
  this.syncPowerReasons();
3619
- this.timers.push(setInterval(() => {
4136
+ this.pollTimer = this.timers.interval(() => {
3620
4137
  this.schedulePoll();
3621
- }, SESSION_POLL_MS));
3622
- this.timers.push(setInterval(() => {
3623
- this.scheduleTick(false);
3624
- }, SCHEDULE_TICK_MS));
4138
+ }, SESSION_POLL_MS);
3625
4139
  this.schedulePoll();
3626
- this.scheduleTick(true);
4140
+ this.recoverSchedule();
3627
4141
  }
3628
4142
  setConfiguration(active, preventIdleSleep) {
3629
4143
  const resumed = !this.active && active;
@@ -3640,14 +4154,14 @@ var TaskBoardHostService = class {
3640
4154
  this.power.setEnabled(active && preventIdleSleep);
3641
4155
  if (resumed) {
3642
4156
  this.schedulePoll();
3643
- this.scheduleTick(true);
3644
- }
4157
+ this.recoverSchedule();
4158
+ } else if (!active) this.clearScheduleTimer();
3645
4159
  this.emit();
3646
4160
  }
3647
4161
  snapshot() {
3648
4162
  const state = this.ledger.state();
3649
4163
  return {
3650
- schemaVersion: 3,
4164
+ schemaVersion: 4,
3651
4165
  revision: state.revision,
3652
4166
  tasks: state.tasks,
3653
4167
  scheduler: state.scheduler,
@@ -3679,8 +4193,9 @@ var TaskBoardHostService = class {
3679
4193
  }
3680
4194
  const result = this.ledger.applyRequest(requestId, action, initiator);
3681
4195
  if (result.runs !== void 0) this.dispatchRuns(result.runs);
4196
+ if (SCHEDULE_WRITE_ACTIONS.has(action.kind)) this.refreshSchedule();
3682
4197
  return {
3683
- schemaVersion: 3,
4198
+ schemaVersion: 4,
3684
4199
  revision: result.state.revision,
3685
4200
  tasks: result.state.tasks,
3686
4201
  scheduler: result.state.scheduler,
@@ -3689,22 +4204,26 @@ var TaskBoardHostService = class {
3689
4204
  }
3690
4205
  dispose() {
3691
4206
  this.disposed = true;
3692
- for (const timer of this.timers.splice(0)) clearInterval(timer);
4207
+ this.clearScheduleTimer();
4208
+ this.pollTimer?.();
4209
+ this.pollTimer = void 0;
3693
4210
  this.power.dispose();
3694
4211
  this.ledger.dispose();
3695
4212
  this.listeners.clear();
3696
4213
  }
3697
- async launch(opened, others = []) {
4214
+ async launch(opened, others = [], schedule) {
3698
4215
  try {
3699
4216
  const team = opened.task.teamRun === true;
3700
4217
  const reuseSessionId = team ? void 0 : reusableSessionId(opened.task, this.idleSessionIds);
3701
- const promptContext = others.length === 0 ? void 0 : {
3702
- peers: others.map((other) => ({
3703
- id: other.task.id,
3704
- title: other.task.title,
3705
- ...team ? { name: teammateName(other.task.title, other.execution.runGroupId ?? other.task.id) } : {}
3706
- })),
3707
- ...team ? { team: true } : {}
4218
+ const peers = others.length === 0 ? void 0 : others.map((other) => ({
4219
+ id: other.task.id,
4220
+ title: other.task.title,
4221
+ ...team ? { name: teammateName(other.task.title, other.execution.runGroupId ?? other.task.id, other.task.id) } : {}
4222
+ }));
4223
+ const promptContext = peers === void 0 && schedule === void 0 ? void 0 : {
4224
+ ...peers === void 0 ? {} : { peers },
4225
+ ...team ? { team: true } : {},
4226
+ ...schedule === void 0 ? {} : { schedule }
3708
4227
  };
3709
4228
  const sessionId = await this.runner.launch(opened.task, {
3710
4229
  ...reuseSessionId === void 0 ? {} : { reuseSessionId },
@@ -3738,7 +4257,7 @@ var TaskBoardHostService = class {
3738
4257
  try {
3739
4258
  const member = await team.spawn({
3740
4259
  leadSessionId,
3741
- name: teammateName(opened.task.title, opened.execution.runGroupId ?? opened.task.id),
4260
+ name: teammateName(opened.task.title, opened.execution.runGroupId ?? opened.task.id, opened.task.id),
3742
4261
  description: opened.task.title,
3743
4262
  prompt: promptText(opened.task)
3744
4263
  });
@@ -3766,6 +4285,7 @@ var TaskBoardHostService = class {
3766
4285
  return;
3767
4286
  }
3768
4287
  this.idleSessionIds = new Set(running.items.filter((item) => !item.running).map((item) => item.sessionId));
4288
+ this.ledger.finalizeReadyRuns();
3769
4289
  const runtime = this.ledger.runtimeView();
3770
4290
  this.power.updateReasons({
3771
4291
  runningSessions: running.count,
@@ -3779,26 +4299,102 @@ var TaskBoardHostService = class {
3779
4299
  for (const execution of executions) {
3780
4300
  if (execution.sessionId === void 0) continue;
3781
4301
  try {
3782
- const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions);
3783
- if (result.outcome === "pending") continue;
4302
+ const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions, { whileRunning: execution.teamMember });
4303
+ if (result.outcome === "pending") {
4304
+ if (result.unreadable === true) this.noteUnreadableInspection(execution, result.reason);
4305
+ else this.unreadablePolls.delete(execution.executionId);
4306
+ continue;
4307
+ }
4308
+ this.unreadablePolls.delete(execution.executionId);
3784
4309
  this.ledger.settle(execution.taskId, execution.executionId, result.outcome, "error" in result ? result.error : void 0);
3785
4310
  } catch {}
3786
4311
  }
4312
+ const open = new Set(executions.map((execution) => execution.executionId));
4313
+ for (const executionId of [...this.unreadablePolls.keys()]) if (!open.has(executionId)) this.unreadablePolls.delete(executionId);
3787
4314
  }
3788
- async tickSchedule(first) {
4315
+ /**
4316
+ * Count one poll whose session history could not be read. A reader failure is
4317
+ * not progress: the session is not running (the runner only reads history for
4318
+ * one that is idle), so nothing will ever change that verdict. After
4319
+ * {@link UNREADABLE_SETTLE_POLLS} consecutive polls the execution is reported
4320
+ * failed with the recorded reason, instead of holding its card — and every
4321
+ * ancestor of it — in the running column with no way out. The first poll of
4322
+ * each streak is logged, so the Host log names the session.
4323
+ */
4324
+ noteUnreadableInspection(execution, reason) {
4325
+ const polls = (this.unreadablePolls.get(execution.executionId) ?? 0) + 1;
4326
+ this.unreadablePolls.set(execution.executionId, polls);
4327
+ const detail = reason ?? "no reason reported";
4328
+ if (polls === 1) safeConsoleError("[dsh-task-board] execution session " + (execution.sessionId ?? "unknown") + " history is unreadable; it stays pending for up to 24 polls: " + detail);
4329
+ if (polls < UNREADABLE_SETTLE_POLLS) return;
4330
+ this.unreadablePolls.delete(execution.executionId);
4331
+ this.ledger.settle(execution.taskId, execution.executionId, "failed", "execution session history is unreadable (24 consecutive polls); the outcome cannot be determined: " + detail);
4332
+ }
4333
+ /** Drop the armed schedule timer and forget its target. */
4334
+ clearScheduleTimer() {
4335
+ this.scheduleTimer?.();
4336
+ this.scheduleTimer = void 0;
4337
+ this.scheduleTarget = void 0;
4338
+ }
4339
+ /**
4340
+ * Boot / resume recovery: skip every occurrence that came due while the
4341
+ * board was not running and roll each schedule to its next future target,
4342
+ * then arm the timer for the nearest one. Rendering the occurrence is
4343
+ * deliberately not attempted: the ACL of a card that fired hours ago is
4344
+ * stale, and the board's own recovery contract is "missed triggers are
4345
+ * skipped, never replayed".
4346
+ */
4347
+ recoverSchedule() {
4348
+ if (this.disposed) return;
4349
+ this.clearScheduleTimer();
4350
+ const now = this.now();
4351
+ this.ledger.setScheduler({ lastTickAt: now });
4352
+ this.ledger.skipMissed(now);
4353
+ this.armSchedule();
4354
+ }
4355
+ /**
4356
+ * Arm the native timer at the nearest armed future trigger. One timer serves
4357
+ * every schedule: the ledger's next target is the only instant the Host has
4358
+ * to wake for. A target beyond the platform's timer ceiling re-arms in
4359
+ * segments, and a target already past (the wall clock jumped, or the process
4360
+ * was suspended) is handled immediately as a recovery.
4361
+ */
4362
+ armSchedule() {
4363
+ if (this.disposed || !this.active) return;
4364
+ this.clearScheduleTimer();
4365
+ const target = this.ledger.nextArmedRunAt(this.now());
4366
+ if (target === void 0) return;
4367
+ this.scheduleTarget = target;
4368
+ const delay = Math.max(0, Math.min(target - this.now(), MAX_TIMER_DELAY_MS));
4369
+ this.scheduleTimer = this.timers.timeout(() => {
4370
+ this.scheduleTimer = void 0;
4371
+ this.onScheduleFire(target);
4372
+ }, delay);
4373
+ }
4374
+ /**
4375
+ * One armed target became due. A fire landing well past its target is a
4376
+ * resume (suspend, throttle, forward clock jump) rather than a normal
4377
+ * occurrence, so it takes the recovery path instead of launching a run for a
4378
+ * long-stale instant.
4379
+ */
4380
+ onScheduleFire(target) {
3789
4381
  if (this.disposed || !this.active) return;
3790
4382
  const now = this.now();
3791
- const recovered = first || this.lastScheduleTick !== void 0 && now - this.lastScheduleTick > RESUME_GAP_MS;
3792
- this.lastScheduleTick = now;
4383
+ this.scheduleTarget = void 0;
3793
4384
  this.ledger.setScheduler({ lastTickAt: now });
3794
- if (recovered) {
3795
- this.ledger.skipMissed(now);
4385
+ if (now - target > RECOVERY_TOLERANCE_MS) {
4386
+ this.recoverSchedule();
3796
4387
  return;
3797
4388
  }
3798
4389
  for (const schedule of this.ledger.dueSchedules(now)) {
3799
- const next = nextRunAtMs(schedule.cron, schedule.nextRunAt);
3800
- this.dispatchRuns(this.ledger.openScheduled(schedule.taskId, next, now));
4390
+ const next = nextRunAtMs(schedule.cron, schedule.nextRunAt, schedule.timeZone);
4391
+ this.dispatchRuns(this.ledger.openScheduled(schedule.taskId, next, now), {
4392
+ triggeredAt: now,
4393
+ timeZone: schedule.timeZone,
4394
+ cron: schedule.cron
4395
+ });
3801
4396
  }
4397
+ this.armSchedule();
3802
4398
  }
3803
4399
  armedSchedules() {
3804
4400
  return this.ledger.armedScheduleCount();
@@ -3810,15 +4406,15 @@ var TaskBoardHostService = class {
3810
4406
  * others back. A team run's members are spawned inside the root's Lead
3811
4407
  * session instead, once that session exists.
3812
4408
  */
3813
- dispatchRuns(runs) {
4409
+ dispatchRuns(runs, schedule) {
3814
4410
  if (runs.length === 0) return;
3815
4411
  const root = runs.find((run) => run.dispatch !== "teammate") ?? runs[0];
3816
4412
  const others = runs.filter((run) => run !== root);
3817
- this.scheduleLaunch(root, others);
3818
- for (const run of others) if (run.dispatch !== "teammate") this.scheduleLaunch(run);
4413
+ this.scheduleLaunch(root, others, schedule);
4414
+ for (const run of others) if (run.dispatch !== "teammate") this.scheduleLaunch(run, [], schedule);
3819
4415
  }
3820
- scheduleLaunch(opened, others = []) {
3821
- this.launch(opened, others).catch((error) => {
4416
+ scheduleLaunch(opened, others = [], schedule) {
4417
+ this.launch(opened, others, schedule).catch((error) => {
3822
4418
  safeConsoleError("[dsh-task-board] execution launch settlement failed", error);
3823
4419
  });
3824
4420
  }
@@ -3831,14 +4427,18 @@ var TaskBoardHostService = class {
3831
4427
  this.pollInFlight = false;
3832
4428
  });
3833
4429
  }
3834
- scheduleTick(first) {
3835
- if (this.tickInFlight || this.disposed) return;
3836
- this.tickInFlight = true;
3837
- this.tickSchedule(first).catch((error) => {
3838
- safeConsoleError("[dsh-task-board] scheduler tick failed", error);
3839
- }).finally(() => {
3840
- this.tickInFlight = false;
3841
- });
4430
+ /**
4431
+ * Re-arm from the ledger's current targets. Callers that just changed a
4432
+ * schedule (the host routes, the agent tools) invoke this after the write
4433
+ * commits, so a new or edited trigger arms without waiting for the next fire.
4434
+ */
4435
+ refreshSchedule() {
4436
+ if (this.disposed) return;
4437
+ if (!this.active) {
4438
+ this.clearScheduleTimer();
4439
+ return;
4440
+ }
4441
+ this.armSchedule();
3842
4442
  }
3843
4443
  syncPowerReasons() {
3844
4444
  const current = this.power.snapshot();
@@ -4058,6 +4658,7 @@ function scheduleView(schedule) {
4058
4658
  return {
4059
4659
  enabled: schedule.enabled,
4060
4660
  cron: schedule.cron,
4661
+ ...schedule.timeZone === void 0 ? {} : { timeZone: schedule.timeZone },
4061
4662
  ...schedule.nextRunAt === void 0 ? {} : { nextRunAt: schedule.nextRunAt },
4062
4663
  ...schedule.lastTriggeredAt === void 0 ? {} : { lastTriggeredAt: schedule.lastTriggeredAt }
4063
4664
  };
@@ -4107,6 +4708,7 @@ function taskSummary(task, tasks, sessionDefault) {
4107
4708
  ...task.model === void 0 ? {} : { model: task.model },
4108
4709
  ...task.reuseSession === true ? { reuseSession: true } : {},
4109
4710
  ...task.teamRun === true ? { teamRun: true } : {},
4711
+ ...task.goalRun === false ? { goalRun: false } : {},
4110
4712
  ...task.schedule === void 0 ? {} : { schedule: scheduleView(task.schedule) },
4111
4713
  ...task.freeze === void 0 ? {} : { continuationCard: true },
4112
4714
  ...task.permissionConfirmedAt === void 0 ? {} : { permissionConfirmedAt: task.permissionConfirmedAt },
@@ -4299,11 +4901,12 @@ function buildManageTool(host) {
4299
4901
  return defineTool({
4300
4902
  name: "task_board_manage",
4301
4903
  description: [
4302
- "Move, archive, restore, or delete one task board card.",
4904
+ "Move, archive, restore, delete, or settle one task board card.",
4303
4905
  "move-todo and move-backlog are the manual column moves; a running card cannot be moved.",
4906
+ "settle force-closes the open execution of a card the board can no longer observe (a stuck running card) and records it cancelled with the caller as the reason, so the card returns to the todo column and can be run again.",
4304
4907
  "archive takes the whole subtask tree off the board and is refused while any member has an unsettled execution; restore brings the task, its ancestors and its subtree back; delete removes one card and is refused while it still has subtasks (detach or delete them first) or while it runs.",
4305
4908
  "It cannot confirm a permission binding: the confirmation gate is a human act performed in the board UI.",
4306
- "Triggers: 任务看板, task board, 看板, 归档, archive, 删除任务, delete task, 移动任务, move task."
4909
+ "Triggers: 任务看板, task board, 看板, 归档, archive, 删除任务, delete task, 移动任务, move task, 卡住, stuck, 强制结算, settle."
4307
4910
  ].join(" "),
4308
4911
  parameters: {
4309
4912
  taskId: {
@@ -4319,7 +4922,8 @@ function buildManageTool(host) {
4319
4922
  "move-backlog",
4320
4923
  "archive",
4321
4924
  "restore",
4322
- "delete"
4925
+ "delete",
4926
+ "settle"
4323
4927
  ],
4324
4928
  description: "The lifecycle operation to perform."
4325
4929
  }
@@ -4345,6 +4949,10 @@ function buildManageTool(host) {
4345
4949
  kind: "archive",
4346
4950
  taskId: args.taskId
4347
4951
  },
4952
+ settle: {
4953
+ kind: "settle",
4954
+ taskId: args.taskId
4955
+ },
4348
4956
  restore: {
4349
4957
  kind: "restore",
4350
4958
  taskId: args.taskId
@@ -4380,7 +4988,8 @@ function buildScheduleTool(host) {
4380
4988
  return defineTool({
4381
4989
  name: "task_board_schedule",
4382
4990
  description: [
4383
- "Arm, change, or disarm a task scheduled runs (5-field cron in the Host local time zone; day-of-month and day-of-week use OR semantics).",
4991
+ "Arm, change, or disarm a task scheduled runs (5-field cron; day-of-month and day-of-week follow Vixie semantics: both restricted means OR, otherwise AND).",
4992
+ "The cron wall clock is read in the rule stored IANA time zone; omit timeZone to use the Host zone reported by task_board_list/task_board_get. DST gaps are skipped and an ambiguous fall-back time fires once, at the earlier instant.",
4384
4993
  "A due scheduled task runs the same cascade a manual run does, so a root task runs its whole subtask tree.",
4385
4994
  "A schedule whose tree contains an unconfirmed above-default permission is refused and rolls to its next occurrence; the reason is reported in the list board summary as schedulerError.",
4386
4995
  "Missed occurrences during Host downtime are skipped, never queued; a task that is already running skips its occurrence.",
@@ -4399,6 +5008,10 @@ function buildScheduleTool(host) {
4399
5008
  cron: {
4400
5009
  type: "string",
4401
5010
  description: "5-field cron expression: minute hour day-of-month month day-of-week."
5011
+ },
5012
+ timeZone: {
5013
+ type: "string",
5014
+ description: "IANA zone the cron wall clock is read in (for example Asia/Shanghai). Omit to keep the stored zone, or pass an empty string to clear it back to the Host zone."
4402
5015
  }
4403
5016
  },
4404
5017
  output: {
@@ -4406,11 +5019,12 @@ function buildScheduleTool(host) {
4406
5019
  render: renderJson
4407
5020
  },
4408
5021
  async execute(args, exec) {
4409
- if (args.enabled === void 0 && args.cron === void 0) return refused("nothing-to-change", "pass enabled and/or cron");
5022
+ if (args.enabled === void 0 && args.cron === void 0 && args.timeZone === void 0) return refused("nothing-to-change", "pass enabled and/or cron and/or timeZone");
4410
5023
  if (!host.snapshot().tasks.some((task) => task.id === args.taskId)) return refused("task-not-found", "no task with id " + args.taskId);
4411
5024
  const patch = {
4412
5025
  ...args.enabled === void 0 ? {} : { enabled: args.enabled },
4413
- ...args.cron === void 0 ? {} : { cron: args.cron }
5026
+ ...args.cron === void 0 ? {} : { cron: args.cron },
5027
+ ...args.timeZone === void 0 ? {} : { timeZone: args.timeZone === "" ? null : args.timeZone }
4414
5028
  };
4415
5029
  try {
4416
5030
  const snapshot = host.apply(crypto.randomUUID(), {
@@ -4595,6 +5209,10 @@ function buildCreateTool(host) {
4595
5209
  type: "boolean",
4596
5210
  description: "Run this task as an Agent Team: running it starts one Team Lead session and the Host spawns a teammate per subtask inside it. Omit or false for one independent session per member. Refused when the deployment serves no Agent Teams service."
4597
5211
  },
5212
+ goalRun: {
5213
+ type: "boolean",
5214
+ description: "Start each run with dsh built-in /goal so the session keeps working continuation rounds until the goal completes. Default true (omit to keep it); pass false for a single plain turn."
5215
+ },
4598
5216
  tags: {
4599
5217
  type: "array",
4600
5218
  description: "Labels: the name renders as a badge and drives the board filter; promptPrefix is injected ahead of the execution prompt on every run.",
@@ -4654,6 +5272,7 @@ function buildCreateTool(host) {
4654
5272
  ...args.model === void 0 || args.model === "" ? {} : { model: args.model },
4655
5273
  ...args.reuseSession === true ? { reuseSession: true } : {},
4656
5274
  ...args.teamRun === true ? { teamRun: true } : {},
5275
+ ...args.goalRun === false ? { goalRun: false } : {},
4657
5276
  ...tags.length === 0 ? {} : { tags },
4658
5277
  ...args.schedule === void 0 ? {} : { schedule: {
4659
5278
  enabled: args.schedule.enabled,
@@ -4715,8 +5334,13 @@ function buildUpdateTool(host) {
4715
5334
  description: "New agent preset id; an empty string clears it."
4716
5335
  },
4717
5336
  permission: {
4718
- type: "string",
4719
- enum: [...TASK_PERMISSIONS, ""],
5337
+ oneOf: [{
5338
+ type: "string",
5339
+ enum: [...TASK_PERMISSIONS]
5340
+ }, {
5341
+ type: "string",
5342
+ const: ""
5343
+ }],
4720
5344
  description: "New permission preset; an empty string clears it back to the session default."
4721
5345
  },
4722
5346
  model: {
@@ -4731,6 +5355,10 @@ function buildUpdateTool(host) {
4731
5355
  type: "boolean",
4732
5356
  description: "Switches this task between a plain cascade (one session per member) and an Agent Team run (Lead session plus a teammate per subtask)."
4733
5357
  },
5358
+ goalRun: {
5359
+ type: "boolean",
5360
+ description: "Whether each run starts with dsh built-in /goal (default true). Pass false to run one plain turn instead."
5361
+ },
4734
5362
  tags: {
4735
5363
  type: "array",
4736
5364
  description: "Replacement label set; an empty array clears all labels.",
@@ -4766,6 +5394,7 @@ function buildUpdateTool(host) {
4766
5394
  if (has(args, "permission") && typeof args.permission === "string") patch.permission = args.permission === "" ? void 0 : args.permission;
4767
5395
  if (has(args, "reuseSession")) patch.reuseSession = args.reuseSession === true;
4768
5396
  if (has(args, "teamRun")) patch.teamRun = args.teamRun === true;
5397
+ if (has(args, "goalRun")) patch.goalRun = args.goalRun === false ? false : true;
4769
5398
  if (has(args, "tags")) {
4770
5399
  const tags = args.tags ?? [];
4771
5400
  patch.tags = tags.length === 0 ? null : tags.map((tag) => ({
@@ -4895,13 +5524,47 @@ function resolveAccess(access) {
4895
5524
  };
4896
5525
  }
4897
5526
  /**
5527
+ * Harness browser-auth cookie prefix (dsh-client-connection): the
5528
+ * authority-bound signed cookie the Host mints in exchange for its launch
5529
+ * token. dsh-web's remote channel redeems that token before re-issuing paired
5530
+ * traffic to 127.0.0.1 (packages/dsh-remote-web-ui/src/inner-auth.ts), and the
5531
+ * official DSH Desktop shell keeps its own copy from the same exchange and
5532
+ * attaches it to every request it forwards for the dsh-app://app/ page. The
5533
+ * two constants describe one fact and move together.
5534
+ */
5535
+ const BROWSER_AUTH_COOKIE_PREFIX = "dsh-auth-";
5536
+ /**
5537
+ * Whether a Cookie header carries the Host's browser-auth credential. Only an
5538
+ * application on this machine can hold it: the desktop shell never lets it
5539
+ * reach the page's cookie jar, and SameSite=Strict keeps a cross-site page
5540
+ * from attaching it.
5541
+ * @param header - the raw Cookie header value.
5542
+ */
5543
+ function carriesBrowserAuthCookie(header) {
5544
+ if (header === void 0) return false;
5545
+ return header.split(";").some((segment) => segment.trim().startsWith(BROWSER_AUTH_COOKIE_PREFIX));
5546
+ }
5547
+ /**
4898
5548
  * Browser-signal tripwire, NOT an authority check: a bare curl sends neither
4899
5549
  * header and is refused, but a curl with a forged Origin passes this too.
4900
5550
  * The real boundary is the loopback socket + Host + origin-equality checks
4901
5551
  * in isTrustedTaskBoardRequest below; do not rely on this marker alone.
5552
+ *
5553
+ * A first-party client that presents NEITHER header must still pass. The DSH
5554
+ * Desktop shell serves the Web GUI from dsh-app://app/ and forwards that
5555
+ * page's Host requests itself, deleting origin and sec-fetch-site on the
5556
+ * way (dsh-desktop-host's forwardWebRequest), so the panel's own fetch
5557
+ * arrives marker-less and every route behind this guard answered 403 - which
5558
+ * the panel renders as its board.hostError.unauthorized message. The shell
5559
+ * does attach the Host's browser-auth cookie, redeemed from the Host's launch
5560
+ * URL at startup and deliberately withheld from the page. That credential,
5561
+ * not a header the shell strips, is the browser signal of an application on
5562
+ * this machine; a marker-less, credential-less request stays refused.
4902
5563
  */
4903
5564
  function browserSameOriginMarker(req) {
4904
- return req.headers["sec-fetch-site"] === "same-origin" || typeof req.headers.origin === "string";
5565
+ if (req.headers["sec-fetch-site"] === "same-origin") return true;
5566
+ if (typeof req.headers.origin === "string") return true;
5567
+ return carriesBrowserAuthCookie(req.headers.cookie);
4905
5568
  }
4906
5569
  function sameAuthority(req, host) {
4907
5570
  if (req.headers["sec-fetch-site"] === "cross-site") return false;
@@ -5113,38 +5776,111 @@ function makeTaskBoardRoutes(service, access = {}, options = {}) {
5113
5776
  * the loader accepts a standalone install of the same package side by side;
5114
5777
  * without this guard the second instance would still re-register the same
5115
5778
  * webserver routes, tools, settings namespaces, and system-prompt sections
5116
- * and fail the boot. mountOnce makes the second host apply a no-op for the
5117
- * lifetime of the first instance (the browser half is already deduped by
5118
- * package name in the client module host).
5779
+ * and fail the boot. mountOnce makes a mount of an already-mounted package a
5780
+ * no-op for as long as the first instance lives (the browser half is already
5781
+ * deduped by package name in the client module host).
5782
+ *
5783
+ * A no-op is only safe while the holder is ALIVE. The holder can be disposed
5784
+ * long after the refused mount ran its course: the Host reloads a profile by
5785
+ * creating the new loader entries before the old ones are torn down (a
5786
+ * plugin-manager enable/disable/install write, a settings-driven row reload,
5787
+ * HMR), so the new aggregate shell entry mounts its family plugin while the
5788
+ * previous entry still owns the name. Dropping that refused mount lost the
5789
+ * plugin for good - the previous entry then disposed its own mount, releasing
5790
+ * the name with nobody left to take it, and the family row stayed listed as
5791
+ * active while its host routes 404ed (the task-board panel showed
5792
+ * "board.hostError.notMounted", no degraded record appeared, and only a Host
5793
+ * restart recovered it).
5794
+ *
5795
+ * The refused mount is therefore QUEUED, not dropped, and replayed the moment
5796
+ * the holder releases the name - if the waiting fiber is still alive then. The
5797
+ * single-instance guarantee is unchanged: exactly one mount is live per
5798
+ * package name, and the replay re-enters the guard so a later mount still
5799
+ * dedupes against it.
5119
5800
  *
5120
5801
  * The registry rides a global symbol so two module instances of the same
5121
- * package (npm copy vs repository link) still share one verdict. cordis
5122
- * `ctx.effect` runs its callback immediately and treats the callback's
5802
+ * package (npm copy vs repository link) still share one verdict. That symbol
5803
+ * is a CROSS-REPOSITORY contract, not this file's private state: the four
5804
+ * satellite packages (dsh-skins / dsh-pet / dsh-presets /
5805
+ * dsh-community-plugins) are separate repositories carrying their own copy of
5806
+ * this guard, rebuilt on their own schedule, so the value under `MOUNTED`
5807
+ * must keep the shape every published copy reads (a `Set` of package names
5808
+ * with `has`/`add`/`delete`). The wait queues this guard added therefore live
5809
+ * under their own additive key, and the registry reads back a `Set` even when
5810
+ * some other build left a different value there. Changing `MOUNTED`'s shape
5811
+ * in place broke that contract once: a satellite's legacy copy created a
5812
+ * `Set`, the family's new copy read it as a `Map`, and every family row
5813
+ * mounted after it failed with "claims.get is not a function".
5814
+ *
5815
+ * cordis `ctx.effect` runs its callback immediately and treats the callback's
5123
5816
  * return value as the fiber disposer, so the unmarker is returned, not run.
5124
5817
  */
5818
+ /** Published cross-repository contract: package names currently mounted. */
5125
5819
  const MOUNTED = Symbol.for("dsh-web.mounted-plugins");
5820
+ /** Additive key this guard owns: refused mounts waiting for the name. */
5821
+ const WAITERS = Symbol.for("dsh-web.mounted-plugins.waiters");
5822
+ /**
5823
+ * The shared name registry, always a `Set` whatever another build stored here:
5824
+ * a foreign value (an interim shape, a hand-written global) must not take every
5825
+ * family plugin down with it.
5826
+ * @returns the process-wide set of mounted package names.
5827
+ */
5126
5828
  function mountedSet() {
5127
5829
  const registry = globalThis;
5128
- return registry[MOUNTED] ??= /* @__PURE__ */ new Set();
5830
+ const existing = registry[MOUNTED];
5831
+ if (existing instanceof Set) return existing;
5832
+ const created = /* @__PURE__ */ new Set();
5833
+ registry[MOUNTED] = created;
5834
+ return created;
5835
+ }
5836
+ /** Queue per package name for mounts refused while a holder was alive. */
5837
+ function mountWaiters() {
5838
+ const registry = globalThis;
5839
+ return registry[WAITERS] ??= /* @__PURE__ */ new Map();
5129
5840
  }
5130
5841
  /**
5131
5842
  * Wrap a cordis plugin apply so the package runs at most once per process.
5132
- * The first mount registers normally and unmarks when its fiber disposes;
5133
- * any later mount of the same package name is a no-op.
5843
+ * The first mount registers normally and releases the name when its fiber
5844
+ * disposes; a mount refused while that name is held waits for the release and
5845
+ * then runs, unless its own fiber disposes first.
5134
5846
  * @param packageName - npm package identity shared by every install source.
5135
5847
  * @param fn - the original plugin apply.
5136
5848
  * @returns an apply of the same shape.
5137
5849
  */
5138
5850
  function mountOnce(packageName, fn) {
5139
- return ((...args) => {
5851
+ const mount = (...args) => {
5140
5852
  const mounted = mountedSet();
5141
- if (mounted.has(packageName)) return;
5853
+ const ctx = args[0];
5854
+ if (mounted.has(packageName)) {
5855
+ const waiters = mountWaiters();
5856
+ const queue = waiters.get(packageName) ?? [];
5857
+ let alive = true;
5858
+ const pending = { run: () => {
5859
+ if (alive) mount(...args);
5860
+ } };
5861
+ ctx?.effect?.(() => () => {
5862
+ alive = false;
5863
+ const index = queue.indexOf(pending);
5864
+ if (index >= 0) queue.splice(index, 1);
5865
+ });
5866
+ queue.push(pending);
5867
+ waiters.set(packageName, queue);
5868
+ return;
5869
+ }
5142
5870
  mounted.add(packageName);
5143
- args[0]?.effect?.(() => () => {
5871
+ ctx?.effect?.(() => () => {
5144
5872
  mounted.delete(packageName);
5873
+ const waiters = mountWaiters();
5874
+ const queue = waiters.get(packageName);
5875
+ if (queue === void 0) return;
5876
+ waiters.delete(packageName);
5877
+ for (const waiter of queue.splice(0)) queueMicrotask(() => {
5878
+ waiter.run();
5879
+ });
5145
5880
  });
5146
5881
  return fn(...args);
5147
- });
5882
+ };
5883
+ return mount;
5148
5884
  }
5149
5885
  //#endregion
5150
5886
  //#region src/index.ts
@@ -5228,6 +5964,27 @@ function resolveToolRegistry(ctx) {
5228
5964
  return;
5229
5965
  }
5230
5966
  }
5967
+ /**
5968
+ * Resolve the native cordis timer service (dsh-base's own `cordis-plugin-timer`
5969
+ * row). The board arms its schedule through it so unloading the row clears
5970
+ * every pending timer with the owning fiber, instead of the row tracking raw
5971
+ * process handles by hand. A composition that serves no timer service keeps
5972
+ * working on the process globals.
5973
+ * @param ctx - the plugin context.
5974
+ * @returns the timer face, or undefined when this deployment serves none.
5975
+ */
5976
+ function resolveHostTimers(ctx) {
5977
+ try {
5978
+ const timer = ctx.get("timer");
5979
+ if (timer === void 0) return void 0;
5980
+ return typeof timer.timeout === "function" && typeof timer.interval === "function" ? {
5981
+ timeout: timer.timeout.bind(timer),
5982
+ interval: timer.interval.bind(timer)
5983
+ } : void 0;
5984
+ } catch {
5985
+ return;
5986
+ }
5987
+ }
5231
5988
  /** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
5232
5989
  const DEFAULT_ANNOUNCE = false;
5233
5990
  /**
@@ -5331,6 +6088,7 @@ function applyImpl(ctx, config) {
5331
6088
  workspaceRegistry: ctx.workspaceRegistry,
5332
6089
  sessionDefaultPermission: config?.sessionDefaultPermission ?? "read-only",
5333
6090
  maxSubtaskDepth: maxSubtaskDepth(),
6091
+ timers: resolveHostTimers(ctx),
5334
6092
  team: buildTeamDispatcher(ctx, config?.teamProvider ?? "spawn"),
5335
6093
  commandDispatcher: { async execute(sessionId, line, signal) {
5336
6094
  const agent = ctx.agents.get(sessionId);
@@ -5416,4 +6174,4 @@ function applyImpl(ctx, config) {
5416
6174
  sync();
5417
6175
  }
5418
6176
  //#endregion
5419
- export { Config, DEFAULT_PROXY_TOKEN_ENV, DEFAULT_TEAM_PROVIDER, TASK_BOARD_GUIDANCE, apply, buildTeamDispatcher, inject, readConfigField, resolveAgentTeams, resolveLlmRuntime, resolveProxyAccess, resolveToolRegistry };
6177
+ export { Config, DEFAULT_PROXY_TOKEN_ENV, DEFAULT_TEAM_PROVIDER, TASK_BOARD_GUIDANCE, apply, buildTeamDispatcher, inject, readConfigField, resolveAgentTeams, resolveHostTimers, resolveLlmRuntime, resolveProxyAccess, resolveToolRegistry };