dsh-scheduled-actions 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,6 +19,7 @@
19
19
  - **SchedulerEngine** на `setTimeout` — не привязан к сессии, работает на уровне хоста.
20
20
  - **Валидация времени** через встроенный `Intl.DateTimeFormat` (IANA-зоны, DST) — без внешних зависимостей.
21
21
  - **UI в Настройках**: секция «Запланированные действия» — таблица, фильтр «показать прошедшие», массовое удаление, форма создания/редактирования с проверкой, запуск «выполнить сейчас».
22
+ - **Куда отправлять запрос** (для `agent-prompt`): по умолчанию — первому активному агенту, либо конкретный чат из списка живых, либо «создать чат для события» (имя = названию события), либо «каждый раз в новом чате» (имя = событие + дата запуска). Для создания чата выбирается workspace в форме.
22
23
  - **AI-инструменты** для агентов: `scheduled_action_list`, `scheduled_action_create`, `scheduled_action_update`, `scheduled_action_delete`, `scheduled_action_run_now`.
23
24
  - **RPC-мост** между браузером и хостом через `ctx.connection` (`/scheduled-actions/<method>`).
24
25
  - **Полная обратимость**: при остановке/удалении плагина все таймеры и обработчики корректно отключаются.
package/lib/client.js CHANGED
@@ -59,6 +59,17 @@ window.__ModuleLoader__.load({
59
59
  };
60
60
 
61
61
  const WEEKDAY_NAMES = ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"];
62
+ const OVERDUE_DISMISS_KEY = "dsh-scheduled-actions:overdue-dismissed";
63
+ const loadDismissed = () => {
64
+ try {
65
+ const raw = localStorage.getItem(OVERDUE_DISMISS_KEY);
66
+ const v = raw ? JSON.parse(raw) : {};
67
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
68
+ } catch { return {}; }
69
+ };
70
+ const saveDismissed = (m) => {
71
+ try { localStorage.setItem(OVERDUE_DISMISS_KEY, JSON.stringify(m)); } catch { /* ignore */ }
72
+ };
62
73
 
63
74
  const fmtSchedule = (schedule) => {
64
75
  if (!schedule) return "—";
@@ -114,6 +125,16 @@ window.__ModuleLoader__.load({
114
125
  return () => document.removeEventListener("mousedown", onDown);
115
126
  }, [openMenu]);
116
127
  const [selected, setSelected] = react.useState(new Set());
128
+ const [dismissed, setDismissed] = react.useState(() => loadDismissed());
129
+ const dismissOccurrence = (id, action) => {
130
+ const m = { ...dismissed, [id]: action.nextRunAt ?? null };
131
+ setDismissed(m); saveDismissed(m);
132
+ };
133
+ // Show in the banner only real wall-clock misses the user did not already
134
+ // dismiss for this exact occurrence. on-startup actions auto-run at host start
135
+ // and are never "missed".
136
+ const showInBanner = (a) => a.schedule?.type !== "on-startup"
137
+ && dismissed[a.id] !== (a.nextRunAt ?? null);
117
138
 
118
139
  const toggleOne = (id) => setSelected((prev) => {
119
140
  const n = new Set(prev);
@@ -142,10 +163,16 @@ window.__ModuleLoader__.load({
142
163
  } finally { setBusy(false); }
143
164
  };
144
165
 
145
- // A past/finished action is one already completed/failed (no next run)
146
- // or whose nextRunAt has passed. Active = enabled with a future run.
166
+ // A finished action is completed/failed (no run ahead) or disabled. An
167
+ // ENABLED action whose scheduled time already passed is missed/pending: it
168
+ // stays in the active list so the user can open it and run it manually
169
+ // later instead of burying it among the finished ones.
147
170
  const isPast = (a) => {
148
171
  if (a.state === "completed" || a.state === "failed") return true;
172
+ if (a.state === "idle" && a.enabled && a.nextRunAt) {
173
+ const t = Date.parse(a.nextRunAt);
174
+ if (Number.isFinite(t) && t <= Date.now()) return false; // missed -> pending
175
+ }
149
176
  if (a.state === "idle" && a.nextRunAt) {
150
177
  const t = Date.parse(a.nextRunAt);
151
178
  if (Number.isFinite(t) && t <= Date.now()) return true;
@@ -172,7 +199,7 @@ window.__ModuleLoader__.load({
172
199
  const [list, od] = await Promise.all([rpc("list", {}), rpc("checkOverdue", {})]);
173
200
  if (!alive) return;
174
201
  setActions(Array.isArray(list) ? list : []);
175
- setOverdue(Array.isArray(od) ? od : []);
202
+ setOverdue(Array.isArray(od) ? od.filter(showInBanner) : []);
176
203
  } catch (e) {
177
204
  if (alive) setError(e.message);
178
205
  }
@@ -265,7 +292,19 @@ window.__ModuleLoader__.load({
265
292
  } finally { setBusy(false); }
266
293
  };
267
294
 
268
- const closeOverdue = () => setOverdue([]);
295
+ const dismissOverdue = async (id, action) => {
296
+ // Remember locally first so the message never re-appears on the next
297
+ // visit even before the host endpoint is reachable.
298
+ dismissOccurrence(id, action);
299
+ setOverdue((cur) => cur.filter((a) => a.id !== id));
300
+ setBusy(true); setError(null);
301
+ try {
302
+ await rpc("dismissOverdue", { id });
303
+ } catch (e) {
304
+ // Host lands the persistent dismissal after a DSH restart; the local
305
+ // record above already hides this occurrence in this browser.
306
+ } finally { setBusy(false); }
307
+ };
269
308
  const runOverdue = async (id) => {
270
309
  setBusy(true); setError(null);
271
310
  try {
@@ -288,7 +327,7 @@ window.__ModuleLoader__.load({
288
327
  overdue.map((a) => jsxs("div", { style: { display: "flex", gap: 8, alignItems: "center" }, children: [
289
328
  jsx("span", { style: { flex: 1, fontSize: 13, color: "var(--dsw-alias-label-primary)" }, children: a.label }),
290
329
  jsx(button, { disabled: busy, onClick: () => runOverdue(a.id), children: "Выполнить" }),
291
- jsx(button, { disabled: busy, onClick: closeOverdue, children: "Позже" }),
330
+ jsx(button, { disabled: busy, onClick: () => dismissOverdue(a.id, a), children: "Позже" }),
292
331
  ] }, a.id)),
293
332
  ] }) : null,
294
333
 
@@ -325,9 +364,9 @@ window.__ModuleLoader__.load({
325
364
  jsx("td", { colSpan: 6, style: { ...tdStyle, color: "var(--dsw-alias-label-tertiary)", textAlign: "center" }, children: "Нет действий" }) }) :
326
365
  visibleActions.map((a) => jsxs("tr", { style: rowStyle, children: [
327
366
  jsx("td", { style: { ...tdStyle, ...stickyCheck, width: 34 }, children: isPast(a) ? jsx("input", { type: "checkbox", checked: selected.has(a.id), onChange: () => toggleOne(a.id) }) : null }),
328
- jsx("td", { style: { ...tdStyle, ...stickyName }, children: jsxs("div", { style: { display: "flex", flexDirection: "column" }, children: [
329
- jsx("span", { style: { fontWeight: 500, color: "var(--dsw-alias-label-primary)" }, children: a.label }),
330
- jsx("span", { style: smallText, children: a.description || a.id }),
367
+ jsx("td", { style: { ...tdStyle, ...stickyName }, children: jsxs("div", { style: { position: "relative", height: 36, minWidth: 0 }, children: [
368
+ jsx("span", { title: a.label, style: { display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", lineHeight: "18px", maxWidth: 240, fontWeight: 500, color: "var(--dsw-alias-label-primary)" }, children: a.label }),
369
+ jsx("span", { title: a.description || a.id, style: { ...smallText, position: "absolute", left: 0, right: 0, bottom: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", lineHeight: "12px" }, children: a.description || a.id }),
331
370
  ] }) }),
332
371
  jsx("td", { style: tdStyle, children: fmtSchedule(a.schedule) }),
333
372
  jsx("td", { style: tdStyle, children: a.schedule?.type === "on-startup" ? "При запуске" : fmtDate(a.nextRunAt) }),
@@ -431,8 +470,51 @@ window.__ModuleLoader__.load({
431
470
  const [startMode, setStartMode] = react.useState(initial?.schedule?.startMode ?? "everyStart");
432
471
  const [expr, setExpr] = react.useState(initial?.schedule?.cronExpr ?? "0 9 * * *");
433
472
  const [enabled, setEnabled] = react.useState(initial?.enabled ?? true);
473
+ // Chat target picker: "" = default, a session id = existing chat,
474
+ // "__new_chat__" = create one dedicated chat, "__new_per_run__" = fresh chat each run.
475
+ const [chatTarget, setChatTarget] = react.useState(() => {
476
+ if (initial?.chatPolicy === "new-per-run") return "__new_per_run__";
477
+ if (initial?.chatPolicy === "new-chat") return "__new_chat__";
478
+ return initial?.targetAgentId ?? "";
479
+ });
480
+ const [workspaceId, setWorkspaceId] = react.useState(initial?.chatWorkspaceId ?? "");
481
+ const [chats, setChats] = react.useState([]);
482
+ const [chatsState, setChatsState] = react.useState("idle"); // idle | loading | ready | error
483
+ const [workspaces, setWorkspaces] = react.useState([]);
484
+ const [workspacesState, setWorkspacesState] = react.useState("idle");
434
485
  const [formError, setFormError] = react.useState(false);
435
486
 
487
+ react.useEffect(() => {
488
+ let alive = true;
489
+ setChatsState("loading");
490
+ rpc("listChats", {}).then((list) => {
491
+ if (!alive) return;
492
+ setChats(Array.isArray(list) ? list : []);
493
+ setChatsState("ready");
494
+ }, () => {
495
+ if (!alive) return;
496
+ setChats([]);
497
+ setChatsState("error");
498
+ });
499
+ return () => { alive = false; };
500
+ }, []);
501
+
502
+ react.useEffect(() => {
503
+ let alive = true;
504
+ setWorkspacesState("loading");
505
+ rpc("listWorkspaces", {}).then((list) => {
506
+ if (!alive) return;
507
+ setWorkspaces(Array.isArray(list) ? list : []);
508
+ setWorkspacesState("ready");
509
+ setWorkspaceId((cur) => cur || (Array.isArray(list) && list[0] ? list[0].id : ""));
510
+ }, () => {
511
+ if (!alive) return;
512
+ setWorkspaces([]);
513
+ setWorkspacesState("error");
514
+ });
515
+ return () => { alive = false; };
516
+ }, []);
517
+
436
518
  const showInvalid = () => setFormError(true);
437
519
 
438
520
  // Domain schedules "daily"/"weekly" in the browser's local IANA zone.
@@ -466,7 +548,19 @@ window.__ModuleLoader__.load({
466
548
  else if (type === "cron") schedule = { type: "cron", cronExpr: expr };
467
549
  else if (type === "on-startup") schedule = { type: "on-startup", startMode };
468
550
  else schedule = { type: "one-shot", at: new Date(Date.now() + 3600000).toISOString() };
469
- onSave({ label, description, kind, payload, schedule, enabled });
551
+ // Chat target: existing chat id -> targetAgentId; new-chat modes ->
552
+ // chatPolicy + chatWorkspaceId (host creates the session on dispatch).
553
+ let chatFields = { targetAgentId: undefined, chatPolicy: undefined, chatWorkspaceId: undefined };
554
+ if (kind === "agent-prompt") {
555
+ if (chatTarget === "__new_chat__") {
556
+ chatFields = { chatPolicy: "new-chat", chatWorkspaceId: workspaceId || undefined };
557
+ } else if (chatTarget === "__new_per_run__") {
558
+ chatFields = { chatPolicy: "new-per-run", chatWorkspaceId: workspaceId || undefined };
559
+ } else if (chatTarget && chatTarget !== "") {
560
+ chatFields = { targetAgentId: chatTarget };
561
+ }
562
+ }
563
+ onSave({ label, description, kind, payload, schedule, enabled, ...chatFields });
470
564
  };
471
565
 
472
566
  const field = (lbl, value, set, ph = "", type = "text", highlight = false) => jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [
@@ -494,6 +588,28 @@ window.__ModuleLoader__.load({
494
588
  jsx("span", { style: fieldLabel, children: kind === "agent-prompt" ? "Текст запроса" : "Команда" }),
495
589
  jsx("textarea", { value: payload, rows: 3, style: { ...inputStyle, resize: "vertical", font: "inherit", padding: "8px 12px", ...(formError && !payload.trim() ? errorInputStyle : {}) }, onChange: (e) => setPayload(e.target.value) }),
496
590
  ] }),
591
+ kind === "agent-prompt" ? jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [
592
+ jsx("span", { style: fieldLabel, children: "Куда выполнять запрос (необязательно)" }),
593
+ jsx("select", { value: chatTarget, style: inputStyle, onChange: (e) => setChatTarget(e.target.value), children: [
594
+ jsx("option", { value: "", children: "— по умолчанию (первый активный агент) —" }),
595
+ chatsState === "loading" ? jsx("option", { value: "", disabled: true, children: "Загрузка чатов…" }) : null,
596
+ chatsState === "error" ? jsx("option", { value: "", disabled: true, children: "Список чатов недоступен" }) : null,
597
+ chats.map((c) => jsx("option", { key: c.id, value: c.id, children: `${c.title} (${c.id})` })),
598
+ jsx("option", { value: "__new_chat__", children: "+ Создать чат для события (имя = названию)" }),
599
+ jsx("option", { value: "__new_per_run__", children: "+ Каждый раз в новом чате (имя + дата)" }),
600
+ ] }),
601
+ (chatTarget === "__new_chat__" || chatTarget === "__new_per_run__") ? jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [
602
+ jsx("span", { style: fieldLabel, children: "Где создать чат (workspace)" }),
603
+ jsx("select", { value: workspaceId, style: inputStyle, onChange: (e) => setWorkspaceId(e.target.value), children: [
604
+ workspacesState === "loading" ? jsx("option", { value: "", children: "Загрузка workspace…" }) : null,
605
+ workspaces.map((w) => jsx("option", { key: w.id, value: w.id, children: w.title })),
606
+ ] }),
607
+ jsx("span", { style: { ...smallText }, children: chatTarget === "__new_chat__"
608
+ ? `Чат создастся при первом срабатывании с именем «${label || "название события"}».`
609
+ : `При каждом срабатывании создастся новый чат с именем «${label || "название события"} + дата».` }),
610
+ ] }) : null,
611
+ jsx("span", { style: { ...smallText }, children: "Не выбрано — запрос уйдёт первому активному агенту, как сейчас." }),
612
+ ] }) : null,
497
613
  sel("Расписание", type, setType, [
498
614
  { v: "one-shot", t: "Одноразово" },
499
615
  { v: "interval", t: "Интервал" },
@@ -742,4 +858,4 @@ window.__ModuleLoader__.load({
742
858
  module.exports = { apply, inject };
743
859
  return module.exports;
744
860
  }
745
- });
861
+ });
package/lib/domain.js CHANGED
@@ -576,6 +576,13 @@ export function createScheduledAction(input, now) {
576
576
  // executer delivers to the first root agent (or the invoking agent, for
577
577
  // actions created by a tool). Preserved through decode; not validated.
578
578
  targetAgentId: typeof input.targetAgentId === "string" ? input.targetAgentId : undefined,
579
+ // Chat-target policy for `agent-prompt` actions:
580
+ // - "new-chat": create ONE dedicated chat (in chatWorkspaceId) on the
581
+ // first run and remember its session as targetAgentId;
582
+ // - "new-per-run": create a fresh chat on every run (title = label + run date).
583
+ // Preserved through decode; not validated further.
584
+ chatPolicy: typeof input.chatPolicy === "string" ? input.chatPolicy : undefined,
585
+ chatWorkspaceId: typeof input.chatWorkspaceId === "string" ? input.chatWorkspaceId : undefined,
579
586
  });
580
587
  }
581
588
 
package/lib/execution.js CHANGED
@@ -96,19 +96,41 @@ function resolveTargetAgent(ctx, action) {
96
96
  return roots[0];
97
97
  }
98
98
 
99
+ /** Human-readable local run stamp, e.g. "2026-09-07 14:30". */
100
+ function runStamp(now) {
101
+ const d = new Date(now);
102
+ const p = (n) => String(n).padStart(2, "0");
103
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
104
+ }
105
+
99
106
  /**
100
107
  * Build the default host executer closure.
101
108
  *
102
109
  * @param ctx - Host context (must inject `agents` when the agent-prompt kind is
103
110
  * used; a missing `agents` service makes agent-prompt actions fail cleanly).
111
+ * @param createChatFn - Optional async `({ workspaceId, title }) => Promise<{ sessionId }>`
112
+ * used by the `chatPolicy: "new-chat"` / `"new-per-run"` modes.
104
113
  * @returns Async `(action) => output` for the configured service executer.
105
114
  */
106
- export function createExecuter(ctx) {
115
+ export function createExecuter(ctx, createChatFn) {
107
116
  return async function executer(action) {
108
117
  if (action.kind === "shell-command") {
109
118
  return runShellCommand(action.payload);
110
119
  }
111
120
  if (action.kind === "agent-prompt") {
121
+ const agents = ctx.agents;
122
+ const policy = action.chatPolicy;
123
+ // new-per-run: create a fresh chat on EVERY run with a run-stamped title.
124
+ if (policy === "new-per-run" && createChatFn && action.chatWorkspaceId) {
125
+ const title = action.label.trim().length > 0
126
+ ? `${action.label} · ${runStamp(Date.now())}`
127
+ : `Запланированное событие · ${runStamp(Date.now())}`;
128
+ const created = await createChatFn({ workspaceId: action.chatWorkspaceId, title });
129
+ const sessionId = created?.sessionId;
130
+ const agent = agents && sessionId ? agents.get(sessionId) : undefined;
131
+ if (!agent) throw new Error(`agent-prompt (new-per-run): chat "${sessionId ?? "(none)"}" is not a live agent`);
132
+ return deliverAgentPrompt(ctx, agent, action.payload);
133
+ }
112
134
  const agent = resolveTargetAgent(ctx, action);
113
135
  if (!agent) throw new Error(`agent-prompt action "${action.label}" has no target agent`);
114
136
  return deliverAgentPrompt(ctx, agent, action.payload);
package/lib/index.js CHANGED
@@ -24,6 +24,79 @@ export const name = "scheduled-actions";
24
24
  */
25
25
  export const inject = ["agents"];
26
26
 
27
+ /** Latest user/auto session title from a live session's own durable events. */
28
+ function sessionTitleOf(session) {
29
+ try {
30
+ const events = session?.ownEvents?.();
31
+ if (!events) return undefined;
32
+ const titleEvent = [...events].reverse().find((e) => e?.type === "session/title");
33
+ return titleEvent?.data?.title ?? undefined;
34
+ } catch {
35
+ return undefined;
36
+ }
37
+ }
38
+
39
+ /** Live top-level chats (root agents) with a readable title for the picker. */
40
+ function listLiveChats(ctx) {
41
+ const agents = ctx.get ? ctx.get("agents") : undefined;
42
+ if (!agents || typeof agents.roots !== "function") return [];
43
+ return agents.roots().map((agent) => ({
44
+ id: agent.id,
45
+ title: sessionTitleOf(agent.session) ?? agent.id,
46
+ }));
47
+ }
48
+
49
+ /** Durable workspaces available for creating a new chat in. */
50
+ function listWorkspaces(ctx) {
51
+ const registry = ctx.get ? ctx.get("workspaceRegistry") : undefined;
52
+ if (!registry || typeof registry.list !== "function") return [];
53
+ try {
54
+ return registry.list().map((w) => ({
55
+ id: w.id,
56
+ title: w.title ?? w.record?.title ?? w.path ?? w.id,
57
+ path: w.path ?? w.record?.path ?? "",
58
+ }));
59
+ } catch {
60
+ return [];
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Create a new chat (session + agent) in the given workspace and give it a
66
+ * title, using the same host sessionController the web GUI calls. Returns the
67
+ * new session id, or throws when the service is unavailable / creation fails.
68
+ */
69
+ async function createChat(ctx, payload) {
70
+ const sessionController = ctx.get ? ctx.get("sessionController") : undefined;
71
+ if (!sessionController || typeof sessionController.create !== "function") {
72
+ const err = new Error("scheduled-actions: sessionController unavailable (web app not mounted)");
73
+ err.code = "scheduled-actions/no-session-controller";
74
+ throw err;
75
+ }
76
+ const workspaceId = payload?.workspaceId;
77
+ if (!workspaceId || typeof workspaceId !== "string") {
78
+ const err = new Error("scheduled-actions: workspaceId is required");
79
+ err.code = "gateway/bad-request";
80
+ throw err;
81
+ }
82
+ const created = await sessionController.create({ workspaceId });
83
+ const sessionId = created?.sessionId;
84
+ if (!sessionId) throw new Error("scheduled-actions: session.create returned no sessionId");
85
+ const title = typeof payload?.title === "string" && payload.title.trim().length > 0
86
+ ? payload.title.trim()
87
+ : undefined;
88
+ if (title !== undefined && typeof sessionController.rename === "function") {
89
+ try {
90
+ await sessionController.rename({ sessionId, title });
91
+ } catch (e) {
92
+ ctx.logger?.warn?.(
93
+ `scheduled-actions: chat "${sessionId}" created but rename to "${title}" failed: ${e && e.message ? e.message : String(e)}`
94
+ );
95
+ }
96
+ }
97
+ return { sessionId };
98
+ }
99
+
27
100
  /** Default store path under the DSH home directory. */
28
101
  function defaultStorePath() {
29
102
  return join(homedir(), ".dsh", "scheduled-actions.json");
@@ -37,6 +110,7 @@ export class ScheduledActionsService {
37
110
  #engine;
38
111
  #logger;
39
112
  #executer;
113
+ #chatProvisioner;
40
114
 
41
115
  /**
42
116
  * @param store - The action store.
@@ -65,6 +139,15 @@ export class ScheduledActionsService {
65
139
  this.#executer = executer;
66
140
  }
67
141
 
142
+ /**
143
+ * Configure the chat provisioner used by `chatPolicy: "new-chat"`: create
144
+ * one dedicated chat for the event on its first run and remember its session
145
+ * as the action's target. Async `({ workspaceId, title }) => { sessionId }`.
146
+ */
147
+ setChatProvisioner(provisioner) {
148
+ this.#chatProvisioner = provisioner;
149
+ }
150
+
68
151
  /** All actions. */
69
152
  list() {
70
153
  return this.#store.list();
@@ -102,6 +185,9 @@ export class ScheduledActionsService {
102
185
  const now = Date.now();
103
186
  const from = current.lastRunAt ?? current.nextRunAt ?? undefined;
104
187
  merged.nextRunAt = computeNextRun(merged.schedule, now, from);
188
+ // The occurrence moved on: forget any earlier «Позже» dismissal, so a
189
+ // genuinely missed later run can be reported again.
190
+ merged.dismissedNextRunAt = undefined;
105
191
  }
106
192
  await this.#store.put(merged);
107
193
  this.#engine.requestDrive();
@@ -140,6 +226,19 @@ export class ScheduledActionsService {
140
226
  let output = "";
141
227
  let error;
142
228
  try {
229
+ // chatPolicy "new-chat": create the one dedicated chat on the first run
230
+ // and remember its session as the action's target so later runs reuse it.
231
+ if (
232
+ action.kind === "agent-prompt"
233
+ && action.chatPolicy === "new-chat"
234
+ && !action.targetAgentId
235
+ && this.#chatProvisioner
236
+ && action.chatWorkspaceId
237
+ ) {
238
+ const title = action.label.trim().length > 0 ? action.label.trim() : "Запланированное событие";
239
+ const created = await this.#chatProvisioner({ workspaceId: action.chatWorkspaceId, title });
240
+ if (created?.sessionId) action = { ...action, targetAgentId: created.sessionId };
241
+ }
143
242
  output = await executer(action);
144
243
  success = true;
145
244
  } catch (err) {
@@ -153,6 +252,9 @@ export class ScheduledActionsService {
153
252
  runCount: action.runCount + 1,
154
253
  state: success ? "idle" : "failed",
155
254
  updatedAt: finishedAt,
255
+ // This occurrence is now handled (ran, or the schedule moved past it):
256
+ // clear any earlier «Позже» dismissal so future misses can be reported.
257
+ dismissedNextRunAt: undefined,
156
258
  };
157
259
  if (action.schedule.type === "one-shot" && success) {
158
260
  updated.state = "completed";
@@ -218,17 +320,37 @@ export class ScheduledActionsService {
218
320
  return this.#run(action, runner);
219
321
  }
220
322
 
221
- /** Actions that are currently overdue (enabled and nextRunAt in the past). */
323
+ /** Actions that are currently overdue: enabled wall-clock actions whose
324
+ * scheduled time passed without running, minus occurrences the user already
325
+ * dismissed with «Позже». on-startup actions self-run at each host start and
326
+ * have no wall-clock deadline, so they are never reported as missed here. */
222
327
  checkOverdue() {
223
328
  const now = Date.now();
224
329
  return this.#store.list().filter((action) => {
225
330
  if (!action.enabled) return false;
226
- if (action.schedule.type === "on-startup") return true;
331
+ if (action.schedule?.type === "on-startup") return false;
227
332
  if (action.nextRunAt === null) return false;
333
+ if (action.dismissedNextRunAt === action.nextRunAt) return false;
228
334
  return Date.parse(action.nextRunAt) <= now;
229
335
  });
230
336
  }
231
337
 
338
+ /** Remember the user's «Позже» for the current missed occurrence, so the
339
+ * overdue banner does not re-ask on every visit to the scheduler. The action
340
+ * itself is left untouched and stays in the list — it will only run when the
341
+ * user runs it manually (runNow) or its schedule is edited. */
342
+ async dismissOverdue(id) {
343
+ const current = this.#store.get(id);
344
+ if (current === undefined) return undefined;
345
+ const updated = {
346
+ ...current,
347
+ dismissedNextRunAt: current.nextRunAt ?? null,
348
+ updatedAt: new Date().toISOString(),
349
+ };
350
+ await this.#store.put(updated);
351
+ return updated;
352
+ }
353
+
232
354
  /**
233
355
  * Whether an on-startup action should run on THIS host start, given its
234
356
  * startMode and the last successful run.
@@ -299,12 +421,15 @@ export function apply(ctx, config) {
299
421
  // Этап 4: wire the real payload executer (shell-command + agent-prompt).
300
422
  // It reads `ctx.agents` for the agent-prompt delivery path; missing agents
301
423
  // service makes only agent-prompt actions fail cleanly, shell still works.
424
+ const createChatBound = (payload) => createChat(ctx, payload);
302
425
  if (typeof ctx.inject === "function") {
303
426
  ctx.inject(["agents"], (agentCtx) => {
304
- service.setExecuter(createExecuter(agentCtx));
427
+ service.setExecuter(createExecuter(agentCtx, createChatBound));
428
+ service.setChatProvisioner(createChatBound);
305
429
  }, { label: "scheduled-actions.executer()" });
306
430
  } else {
307
- service.setExecuter(createExecuter(ctx));
431
+ service.setExecuter(createExecuter(ctx, createChatBound));
432
+ service.setChatProvisioner(createChatBound);
308
433
  }
309
434
 
310
435
  // Этап 4: register the management tools into every live root agent scope,
@@ -354,6 +479,10 @@ export function apply(ctx, config) {
354
479
  case "toggle": return service.toggle(payload?.id);
355
480
  case "runNow": return service.runNow(payload?.id);
356
481
  case "checkOverdue": return service.checkOverdue();
482
+ case "dismissOverdue": return service.dismissOverdue(payload?.id);
483
+ case "listChats": return listLiveChats(ctx);
484
+ case "listWorkspaces": return listWorkspaces(ctx);
485
+ case "createChat": return createChat(ctx, payload);
357
486
  default: {
358
487
  const err = new Error(`scheduled-actions: unknown endpoint ${endpoint}`);
359
488
  err.code = "gateway/not-found";
package/lib/scheduler.js CHANGED
@@ -79,6 +79,9 @@ export class SchedulerEngine {
79
79
  const target = Date.parse(action.nextRunAt);
80
80
  if (!Number.isFinite(target)) continue;
81
81
  if (target <= now) {
82
+ // The user dismissed this exact missed occurrence («Позже») and wants to
83
+ // run it manually — do not auto-dispatch it now or on later re-arms.
84
+ if (action.dismissedNextRunAt === action.nextRunAt) continue;
82
85
  // Due now — dispatch asynchronously. `#busy` stays true so no other
83
86
  // pass can start a second dispatch of the same (or another) action
84
87
  // while this one is in flight; `#dispatch` releases the flag before
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-scheduled-actions",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Host-level scheduled actions for DeepSeek Harness — create, verify, and run actions on a schedule (one-shot, interval, daily, weekly, cron, on-startup) with a JSON store and a Settings UI.",
5
5
  "keywords": [
6
6
  "cordis",