mixdog 1.3.4 → 1.3.5

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 (197) hide show
  1. package/LICENSES/oh-my-pi-MIT.txt +23 -0
  2. package/NOTICE.md +15 -0
  3. package/README.md +11 -0
  4. package/package.json +2 -1
  5. package/scripts/embed-google-oauth-client.mjs +49 -0
  6. package/scripts/lib/test-spawn-ledger.mjs +46 -1
  7. package/scripts/test.mjs +5 -0
  8. package/src/defaults/shipped-history.json +8 -0
  9. package/src/defaults/skills/browser-use/SKILL.md +21 -9
  10. package/src/defaults/skills/setup/SKILL.md +7 -2
  11. package/src/defaults/skills/setup/references/actions.md +82 -27
  12. package/src/defaults/skills/setup/references/surfaces.md +67 -34
  13. package/src/defaults/skills/setup-guide/SKILL.md +3 -3
  14. package/src/lib/keychain-cjs.cjs +359 -29
  15. package/src/rules/agent-mode/AGENT_MODE.md +10 -2
  16. package/src/rules/agent-mode/MIXY.md +12 -0
  17. package/src/runtime/agent/orchestrator/agent-runtime/heartbeat-completion.mjs +193 -0
  18. package/src/runtime/agent/orchestrator/config.mjs +18 -0
  19. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +2 -2
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
  21. package/src/runtime/agent/orchestrator/mcp/oauth-callback.mjs +31 -2
  22. package/src/runtime/agent/orchestrator/mcp/oauth-client.mjs +16 -3
  23. package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +42 -7
  24. package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +21 -2
  25. package/src/runtime/agent/orchestrator/mcp/oauth-shared.mjs +23 -0
  26. package/src/runtime/agent/orchestrator/mcp/oauth.mjs +11 -1
  27. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +68 -23
  28. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +22 -1
  29. package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +18 -5
  30. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -3
  32. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +52 -8
  33. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +10 -1
  34. package/src/runtime/agent/orchestrator/providers/antigravity-request.mjs +22 -6
  35. package/src/runtime/agent/orchestrator/providers/antigravity-response.mjs +15 -4
  36. package/src/runtime/agent/orchestrator/providers/antigravity-stream.mjs +17 -2
  37. package/src/runtime/agent/orchestrator/providers/antigravity-transport.mjs +19 -2
  38. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/cursor-wire-normalization.mjs +10 -4
  40. package/src/runtime/agent/orchestrator/providers/cursor-wire-protobuf.mjs +10 -217
  41. package/src/runtime/agent/orchestrator/providers/cursor.mjs +7 -0
  42. package/src/runtime/agent/orchestrator/providers/devin-auth.mjs +409 -0
  43. package/src/runtime/agent/orchestrator/providers/devin-wire-families.mjs +187 -0
  44. package/src/runtime/agent/orchestrator/providers/devin-wire-identity.mjs +342 -0
  45. package/src/runtime/agent/orchestrator/providers/devin-wire-models.mjs +541 -0
  46. package/src/runtime/agent/orchestrator/providers/devin-wire-protobuf.mjs +305 -0
  47. package/src/runtime/agent/orchestrator/providers/devin-wire-request.mjs +558 -0
  48. package/src/runtime/agent/orchestrator/providers/devin-wire-schema.mjs +880 -0
  49. package/src/runtime/agent/orchestrator/providers/devin-wire-session.mjs +190 -0
  50. package/src/runtime/agent/orchestrator/providers/devin-wire-tool-args.mjs +351 -0
  51. package/src/runtime/agent/orchestrator/providers/devin-wire-transport.mjs +560 -0
  52. package/src/runtime/agent/orchestrator/providers/devin.mjs +491 -0
  53. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +3 -0
  54. package/src/runtime/agent/orchestrator/providers/gemini-output-limit.mjs +46 -0
  55. package/src/runtime/agent/orchestrator/providers/gemini-reasoning-relay.mjs +30 -0
  56. package/src/runtime/agent/orchestrator/providers/gemini-request-body.mjs +7 -11
  57. package/src/runtime/agent/orchestrator/providers/gemini-retry-policy.mjs +61 -22
  58. package/src/runtime/agent/orchestrator/providers/gemini-sdk-request.mjs +1 -0
  59. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +71 -4
  60. package/src/runtime/agent/orchestrator/providers/gemini.mjs +25 -2
  61. package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
  62. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +96 -5
  63. package/src/runtime/agent/orchestrator/providers/lib/tail-reminder-split.mjs +23 -5
  64. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +17 -0
  65. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +39 -3
  66. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-state.mjs +3 -1
  67. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +2 -0
  68. package/src/runtime/agent/orchestrator/providers/openai-error-contract.mjs +6 -1
  69. package/src/runtime/agent/orchestrator/providers/opencode-go-gemini.mjs +1 -0
  70. package/src/runtime/agent/orchestrator/providers/provider-availability.mjs +19 -0
  71. package/src/runtime/agent/orchestrator/providers/registry.mjs +17 -3
  72. package/src/runtime/agent/orchestrator/providers/wire-protobuf-codec.mjs +368 -0
  73. package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +2 -0
  74. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +1 -1
  75. package/src/runtime/agent/orchestrator/runtime-core/reaction-tool-defs.mjs +133 -0
  76. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-data.mjs +3 -0
  77. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +23 -5
  78. package/src/runtime/agent/orchestrator/runtime-core/tool-profile.mjs +35 -10
  79. package/src/runtime/agent/orchestrator/runtime-core/workflow/agents.mjs +57 -10
  80. package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +9 -6
  81. package/src/runtime/agent/orchestrator/runtime-core/workflow-ids.mjs +1 -1
  82. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +18 -0
  83. package/src/runtime/agent/orchestrator/session/agent-room.d.mts +0 -3
  84. package/src/runtime/agent/orchestrator/session/agent-room.mjs +10 -4
  85. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +15 -3
  86. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +5 -0
  87. package/src/runtime/agent/orchestrator/session/human-gate/gate.mjs +36 -4
  88. package/src/runtime/agent/orchestrator/session/human-gate/outbound-policy.mjs +6 -0
  89. package/src/runtime/agent/orchestrator/session/human-gate/store.mjs +15 -3
  90. package/src/runtime/agent/orchestrator/session/loop/assistant-commit.mjs +8 -0
  91. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +8 -1
  92. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/segments.mjs +4 -1
  93. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +50 -2
  94. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +3 -0
  95. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +11 -2
  96. package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +11 -2
  97. package/src/runtime/agent/orchestrator/session/loop/turn-limits.mjs +60 -0
  98. package/src/runtime/agent/orchestrator/session/manager/ask-turn-commit.mjs +2 -0
  99. package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +14 -0
  100. package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +2 -1
  101. package/src/runtime/agent/orchestrator/session/manager/session-tool-surface.mjs +13 -0
  102. package/src/runtime/agent/orchestrator/session/message-ids.mjs +5 -3
  103. package/src/runtime/agent/orchestrator/session/message-reactions.mjs +89 -0
  104. package/src/runtime/agent/orchestrator/session/message-reactions.test-support.mjs +75 -0
  105. package/src/runtime/agent/orchestrator/session/routine-result.d.mts +0 -2
  106. package/src/runtime/agent/orchestrator/session/routine-result.mjs +11 -2
  107. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +20 -2
  108. package/src/runtime/agent/orchestrator/session/store-agent-worker-header-cache.mjs +85 -0
  109. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +125 -12
  110. package/src/runtime/agent/orchestrator/session/tail-reminder-rules.mjs +11 -5
  111. package/src/runtime/agent/orchestrator/session/tool-batch/state.mjs +4 -0
  112. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -0
  113. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +7 -1
  114. package/src/runtime/channels/lib/schedule-errors.mjs +51 -0
  115. package/src/runtime/channels/lib/scheduler.mjs +109 -14
  116. package/src/runtime/channels/lib/schedules-db.mjs +49 -0
  117. package/src/runtime/channels/lib/tool-dispatch.mjs +13 -0
  118. package/src/runtime/channels/lib/worker-main.mjs +9 -0
  119. package/src/runtime/office/com/com-adapter.mjs +17 -1
  120. package/src/runtime/office/com/office-com-host.ps1 +51 -1
  121. package/src/runtime/office/pdf/cfb-fixture.mjs +328 -0
  122. package/src/runtime/office/pdf/cfb-protection.mjs +311 -0
  123. package/src/runtime/office/pdf/document-preview.mjs +326 -8
  124. package/src/runtime/office/pdf/ooxml-crypto.mjs +469 -0
  125. package/src/runtime/office/pdf/ooxml-key-worker.mjs +14 -0
  126. package/src/runtime/office/portable/portable-soffice.mjs +45 -5
  127. package/src/runtime/shared/bot-hops.mjs +62 -2
  128. package/src/runtime/shared/config.mjs +49 -22
  129. package/src/runtime/shared/connector-catalog.d.mts +4 -2
  130. package/src/runtime/shared/connector-catalog.mjs +16 -8
  131. package/src/runtime/shared/developer-options.mjs +32 -1
  132. package/src/runtime/shared/google-oauth-client.generated.json +4 -0
  133. package/src/runtime/shared/google-oauth-client.mjs +34 -9
  134. package/src/runtime/shared/llm/anthropic-betas.mjs +7 -0
  135. package/src/runtime/shared/llm/anthropic-thinking-contract.mjs +12 -0
  136. package/src/runtime/shared/llm/provider-catalog-registry.mjs +1 -0
  137. package/src/runtime/shared/llm/well-formed.mjs +66 -0
  138. package/src/runtime/shared/provider-accounts.mjs +224 -58
  139. package/src/runtime/shared/provider-api-key.mjs +19 -8
  140. package/src/runtime/shared/routine-trigger.mjs +42 -0
  141. package/src/runtime/shared/tool-execution-contract.mjs +2 -0
  142. package/src/runtime/shared/transcript-metadata.mjs +38 -0
  143. package/src/runtime/shared/user-data-guard.mjs +30 -2
  144. package/src/runtime/shared/worktrees/activity-gate.mjs +12 -5
  145. package/src/session-runtime/agent-home-delivery.mjs +57 -13
  146. package/src/session-runtime/agent-room-delivery.mjs +28 -1
  147. package/src/session-runtime/agent-task-sessions.mjs +292 -79
  148. package/src/session-runtime/boot/begin.mjs +3 -2
  149. package/src/session-runtime/boot/tools.mjs +2 -0
  150. package/src/session-runtime/bot-chain.mjs +32 -0
  151. package/src/session-runtime/bot-status.mjs +7 -4
  152. package/src/session-runtime/channel-config-api.mjs +63 -17
  153. package/src/session-runtime/internal-tool-executor/reaction-tool.mjs +45 -0
  154. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +72 -5
  155. package/src/session-runtime/internal-tool-executor.mjs +4 -0
  156. package/src/session-runtime/mcp-glue/server-catalog.mjs +7 -1
  157. package/src/session-runtime/mixy-heartbeat.mjs +807 -0
  158. package/src/session-runtime/model-route/route-tuning.mjs +10 -6
  159. package/src/session-runtime/owned-automation-run.mjs +33 -12
  160. package/src/session-runtime/provider-auth/login.mjs +7 -0
  161. package/src/session-runtime/provider-models/row-hydration.mjs +13 -2
  162. package/src/session-runtime/routine-home-turn.mjs +33 -6
  163. package/src/session-runtime/schedule-session-run.mjs +7 -3
  164. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-plan.mjs +8 -12
  165. package/src/session-runtime/services/channel-admin.mjs +66 -18
  166. package/src/session-runtime/services/channel-worker.mjs +1 -1
  167. package/src/session-runtime/services/provider-admin.mjs +128 -25
  168. package/src/session-runtime/services/schedule-access.mjs +14 -0
  169. package/src/session-runtime/services/usage-dashboard-model.mjs +1 -0
  170. package/src/session-runtime/session-create/session-options.mjs +7 -4
  171. package/src/session-runtime/settings-system-api.mjs +16 -1
  172. package/src/session-runtime/setup-tool/executor.mjs +49 -14
  173. package/src/session-runtime/setup-tool/extended-actions.mjs +29 -3
  174. package/src/session-runtime/setup-tool/settings-contract.mjs +65 -4
  175. package/src/session-runtime/setup-tool/tool-defs.mjs +30 -3
  176. package/src/session-runtime/task-session-route.mjs +99 -0
  177. package/src/session-runtime/tool-policy/policy-stage.mjs +5 -2
  178. package/src/session-runtime/tool-policy/surface-helpers.mjs +2 -3
  179. package/src/session-runtime/tool-surface.mjs +4 -0
  180. package/src/session-runtime/workflow-agents-api/agent-editor.mjs +98 -21
  181. package/src/session-runtime/workflow-agents-api/workflow-packs.mjs +6 -1
  182. package/src/session-runtime/workspace/transitions.mjs +10 -2
  183. package/src/standalone/daemon-session-control.mjs +88 -20
  184. package/src/standalone/daemon.mjs +8 -0
  185. package/src/standalone/session-protocol.mjs +5 -0
  186. package/src/standalone/session-service/agent-tree/agent-cancel.mjs +6 -2
  187. package/src/standalone/session-service/session-calls/session-turns.mjs +6 -0
  188. package/src/standalone/session-service.mjs +13 -0
  189. package/src/standalone/session-tool-output.mjs +8 -5
  190. package/src/tui/app/model-options.mjs +2 -0
  191. package/src/tui/dist/index.mjs +24 -2
  192. package/src/tui/session/notice-surface.mjs +17 -1
  193. package/src/tui/session/session-api/integrations.mjs +1 -0
  194. package/src/tui/session/session-api/lifecycle.mjs +8 -0
  195. package/src/tui/session/session-api/settings.mjs +14 -2
  196. package/src/tui/session/turn-stream.mjs +17 -1
  197. package/src/tui/session/turn.mjs +22 -2
@@ -37,7 +37,11 @@ import {
37
37
  isTranscriptCancelledStatusText,
38
38
  } from '../../../../shared/tool-execution-contract.mjs';
39
39
  import { toolResultTerminalStatus } from '../../../../shared/tool-status.mjs';
40
- import { transcriptAttachmentFields, transcriptRouteMetadataFields } from '../../../../shared/transcript-metadata.mjs';
40
+ import {
41
+ transcriptAttachmentFields,
42
+ transcriptReactionsField,
43
+ transcriptRouteMetadataFields,
44
+ } from '../../../../shared/transcript-metadata.mjs';
41
45
  import { routineTriggerField } from '../../../../shared/routine-trigger.mjs';
42
46
  import { isImportedMessage } from '../imported-message.mjs';
43
47
  import { isContextAckMessage } from '../compact/user-message-kind.mjs';
@@ -91,6 +95,8 @@ function restoredTranscriptMetadata(message) {
91
95
  // The attachments this prompt carried, by content hash: a restored row
92
96
  // opens/downloads them by id from the attachment store.
93
97
  ...transcriptAttachmentFields(value),
98
+ // The bots' emoji reactions on a user message (message-reactions.mjs).
99
+ ...transcriptReactionsField(value),
94
100
  // A routine fire this turn delivered: painted as its marker row.
95
101
  ...routineTriggerField(value),
96
102
  // Skills this user turn explicitly loaded (header selection or `$name`).
@@ -0,0 +1,51 @@
1
+ // Structured reasons a schedule rename is refused or ends unsure. The app
2
+ // decides on the `code` and the `name` detail (the desktop's
3
+ // shared/schedule-error-codes.ts mirrors these codes), never on the English
4
+ // message, which is for the model and the logs.
5
+
6
+ /** Another schedule already has the name (detail: `name`, the name asked for). */
7
+ export const SCHEDULE_NAME_TAKEN = 'SCHEDULE_NAME_TAKEN';
8
+ /** A run of the schedule is in flight, or another rename of it (detail: `name`). */
9
+ export const SCHEDULE_RENAME_RUNNING = 'SCHEDULE_RENAME_RUNNING';
10
+ /** The row moved but the scheduler did not reload (detail: `name`, the new name). */
11
+ export const SCHEDULE_RENAME_RELOAD_FAILED = 'SCHEDULE_RENAME_RELOAD_FAILED';
12
+ /** The scheduler never answered, so the rename may or may not have applied. */
13
+ export const SCHEDULE_RENAME_UNCERTAIN = 'SCHEDULE_RENAME_UNCERTAIN';
14
+
15
+ const NAMED_CODES = new Set([SCHEDULE_NAME_TAKEN, SCHEDULE_RENAME_RUNNING, SCHEDULE_RENAME_RELOAD_FAILED]);
16
+ const CODES = new Set([...NAMED_CODES, SCHEDULE_RENAME_UNCERTAIN]);
17
+
18
+ /** The fields of `error` a reply may carry across the worker boundary: a
19
+ * schedule code and, for the codes that name a schedule, that public `name`.
20
+ * Any other code or detail stays on this side. */
21
+ export function scheduleErrorFields(error) {
22
+ const code = error?.code;
23
+ if (typeof code !== 'string' || !CODES.has(code)) return {};
24
+ const name = error.details?.name;
25
+ return {
26
+ code,
27
+ ...(NAMED_CODES.has(code) && typeof name === 'string' && name ? { details: { name } } : {}),
28
+ };
29
+ }
30
+
31
+ const coded = (message, code, details = null) =>
32
+ Object.assign(new Error(message), { code }, details ? { details } : {});
33
+
34
+ export const scheduleNameTakenError = (name) =>
35
+ coded(`A schedule named "${name}" already exists.`, SCHEDULE_NAME_TAKEN, { name });
36
+
37
+ export const scheduleRenameRunningError = (name) =>
38
+ coded(`Schedule "${name}" is running. Rename it after the run finishes.`, SCHEDULE_RENAME_RUNNING, { name });
39
+
40
+ export const scheduleRenameReloadError = (name, cause) =>
41
+ coded(
42
+ `Renamed to "${name}", but reloading the scheduler failed (${cause}); it is armed on the next reload.`,
43
+ SCHEDULE_RENAME_RELOAD_FAILED,
44
+ { name }
45
+ );
46
+
47
+ export const scheduleRenameUncertainError = (cause) =>
48
+ coded(
49
+ `The rename may or may not have applied (the scheduler did not answer: ${cause}). Check the schedule list.`,
50
+ SCHEDULE_RENAME_UNCERTAIN
51
+ );
@@ -18,6 +18,7 @@ import {
18
18
  setNextFire,
19
19
  setSkippedUntil,
20
20
  } from './schedules-db.mjs';
21
+ import { scheduleRenameReloadError, scheduleRenameRunningError } from './schedule-errors.mjs';
21
22
  import { appendBuffered } from '../../shared/buffered-appender.mjs';
22
23
  import { tryRead } from './settings.mjs';
23
24
 
@@ -121,6 +122,8 @@ class Scheduler {
121
122
  lastFired = /* @__PURE__ */ new Map();
122
123
  // name -> "YYYY-MM-DDTHH:MM"
123
124
  running = /* @__PURE__ */ new Set();
125
+ // Names whose row is being renamed: no fire starts under them meanwhile.
126
+ renaming = /* @__PURE__ */ new Set();
124
127
  injectFn = null;
125
128
  injectReadyFn = null;
126
129
  pendingCheck = null;
@@ -415,6 +418,12 @@ ${prompt}`;
415
418
  }
416
419
  return;
417
420
  }
421
+ // The name is held in `running` from before the claim until the completion
422
+ // stamps are written, so a rename can neither complete meanwhile nor leave
423
+ // this fire to run under the old name. A fire refused here still claims its
424
+ // slot and is recorded as a skip, never run.
425
+ const refusal = this.fireRefusal(schedule.name);
426
+ if (!refusal) this.running.add(schedule.name);
418
427
  try {
419
428
  const claimed = await claimScheduleRun(schedule.name, scheduledAt, startedAt, nextFireAt);
420
429
  if (!claimed) {
@@ -425,7 +434,11 @@ ${prompt}`;
425
434
  schedule.lastStartedAt = startedAt.toISOString();
426
435
  schedule.lastFiredAt = startedAt.toISOString();
427
436
  this.lastFired.set(schedule.name, startedAt.toISOString());
428
- const fired = await this.fireTimed(schedule, type, { awaitDispatch: true });
437
+ if (refusal) {
438
+ this.recordRefusal(schedule, refusal);
439
+ return;
440
+ }
441
+ const fired = await this.fireTimed(schedule, type, { awaitDispatch: true, reserved: true });
429
442
  // An overlap skip is recorded as a skip, never as a failed run.
430
443
  if (!fired && skippedSince(schedule, startedAt)) return;
431
444
  if (!fired) {
@@ -443,6 +456,8 @@ ${prompt}`;
443
456
  } catch {}
444
457
  process.stderr.write(`mixdog scheduler: ${schedule.name} failed: ${err}\n`);
445
458
  this.notifyFailure(schedule, `run failed: ${err?.message || err}`);
459
+ } finally {
460
+ if (!refusal) this.running.delete(schedule.name);
446
461
  }
447
462
  }
448
463
  stop() {
@@ -521,12 +536,17 @@ ${prompt}`;
521
536
  // Mark done ONLY after a successful fire. A false/throwing fireTimed
522
537
  // (missing prompt, running guard, paused owner, dispatch error) must leave the
523
538
  // one-shot pending so a reload/restart can retry it — never retire it.
539
+ // The name stays in `running` until the fired/done stamps are written, so
540
+ // a rename cannot move the row before they land (onCronFire). A refused
541
+ // fire is left to fireTimedPrompt, which records the skip.
542
+ const reserved = !this.fireRefusal(schedule.name);
543
+ if (reserved) this.running.add(schedule.name);
524
544
  try {
525
545
  // awaitDispatch: for a one-shot we must wait for the actual LLM/relay
526
546
  // dispatch outcome — a truthy fireTimed alone (cron's fire-and-forget
527
547
  // contract) would retire the entry even when schedulerLlm() later
528
548
  // rejects. Only a resolved dispatch counts as a real fire here.
529
- const fired = await this.fireTimed(schedule, type, { awaitDispatch: true });
549
+ const fired = await this.fireTimed(schedule, type, { awaitDispatch: true, reserved });
530
550
  if (!fired && skippedSince(schedule, now)) {
531
551
  logSchedule(`one-shot "${schedule.name}" skipped (previous run in progress) — leaving pending for retry\n`);
532
552
  return;
@@ -542,6 +562,8 @@ ${prompt}`;
542
562
  await markOneShotFailed(schedule);
543
563
  process.stderr.write(`mixdog scheduler: ${schedule.name} one-shot failed: ${err} — leaving pending for retry\n`);
544
564
  this.notifyFailure(schedule, `one-shot failed: ${err?.message || err} — left pending for retry`);
565
+ } finally {
566
+ if (reserved) this.running.delete(schedule.name);
545
567
  }
546
568
  }
547
569
  /** when_at one-shots must honor deferred_until / skipped_until just like
@@ -577,10 +599,60 @@ ${prompt}`;
577
599
  releaseSchedulerRuntime(this);
578
600
  this.start();
579
601
  }
602
+ /** Run `rename` (which moves schedule `name`'s row) while no run of `name`
603
+ * can start or be in flight. The check and the reservation are one
604
+ * synchronous step, and a fire checks for the reservation (fireRefusal)
605
+ * in the same step that adds the name to `running`, which it holds until
606
+ * its completion stamps are written, so a fire either is already in
607
+ * `running` (refused here) or finds the reservation (not fired). */
608
+ async renameLocked(name, rename, { newName = name, reload = async () => {} } = {}) {
609
+ if (this.running.has(name) || this.renaming.has(name)) throw scheduleRenameRunningError(name);
610
+ this.renaming.add(name);
611
+ this.renameBlocked?.delete(name);
612
+ try {
613
+ const saved = await rename();
614
+ if (!saved) return saved;
615
+ try {
616
+ await reload();
617
+ } catch (error) {
618
+ // The row has moved but the reload that arms the new name failed:
619
+ // nothing may keep running under the old one, and the call reports it.
620
+ this.dropSchedule(name);
621
+ throw scheduleRenameReloadError(newName, error?.message || error);
622
+ }
623
+ return saved;
624
+ } catch (error) {
625
+ // A one-shot whose timer a blocked fire consumed is armed again from the
626
+ // loaded schedules, so it fires once (after a failed rename the old
627
+ // entry is still loaded; after a dropped one only the others re-arm).
628
+ if (this.renameBlocked?.has(name) && this.tickTimer) this.restart();
629
+ throw error;
630
+ } finally {
631
+ this.renaming.delete(name);
632
+ this.renameBlocked?.delete(name);
633
+ }
634
+ }
635
+ /** Forget `name` entirely: its cron job, one-shot timer, stamps and loaded
636
+ * entries. Used when its row moved but the reload that re-arms it failed. */
637
+ dropSchedule(name) {
638
+ try {
639
+ this.cronJobs.get(name)?.destroy();
640
+ } catch {}
641
+ this.cronJobs.delete(name);
642
+ clearTimeout(this.oneShotTimers.get(name));
643
+ this.oneShotTimers.delete(name);
644
+ this.lastFired.delete(name);
645
+ this.deferred.delete(name);
646
+ this.nonInteractive = this.nonInteractive.filter((s) => s.name !== name);
647
+ this.interactive = this.interactive.filter((s) => s.name !== name);
648
+ }
580
649
  reloadConfig(nonInteractive, interactive, options = {}) {
581
650
  this.nonInteractive = nonInteractive.filter((s) => s.enabled !== false);
582
651
  this.interactive = interactive.filter((s) => s.enabled !== false);
583
652
  this.promptsDir = join(DATA_DIR, 'prompts');
653
+ // A name that no longer exists must not keep (or later regain) a stamp.
654
+ const live = new Set([...this.nonInteractive, ...this.interactive].map((s) => s.name));
655
+ for (const name of this.lastFired.keys()) if (!live.has(name)) this.lastFired.delete(name);
584
656
  // Defer/skip state is persisted (deferred_until / skipped_until) and
585
657
  // re-read from the reloaded rows, so a reload no longer drops it.
586
658
  this.refreshSkipCache();
@@ -646,20 +718,42 @@ ${prompt}`;
646
718
  }
647
719
  return await this.fireTimedPrompt(schedule, type, prompt, opts);
648
720
  }
649
- /** Fire a timed schedule with the given prompt content */
650
- async fireTimedPrompt(schedule, type, prompt, { awaitDispatch = false } = {}) {
651
- logSchedule(`firing ${schedule.name} (${type})\n`);
652
- if (this.running.has(schedule.name)) {
721
+ /** Why a fire of `name` may not start now: 'renaming', 'running' or null. */
722
+ fireRefusal(name) {
723
+ if (this.renaming.has(name)) return 'renaming';
724
+ return this.running.has(name) ? 'running' : null;
725
+ }
726
+ /** Record a fire refused for `reason` (fireRefusal) as a skip. */
727
+ recordRefusal(schedule, reason) {
728
+ if (reason === 'running') {
653
729
  this.recordSkip(schedule);
730
+ return;
731
+ }
732
+ logSchedule(`${schedule.name}: not fired — being renamed\n`);
733
+ // A skip, not a failure: a one-shot stays pending (fireOneShot) and
734
+ // renameLocked re-arms it if the rename does not go through.
735
+ schedule.lastSkippedAt = new Date().toISOString();
736
+ // Per name: another rename settling meanwhile must not hide this one.
737
+ this.renameBlocked ??= new Set();
738
+ this.renameBlocked.add(schedule.name);
739
+ }
740
+ /** Fire a timed schedule with the given prompt content. `reserved`: the
741
+ * caller already holds the name in `running` and releases it itself. */
742
+ async fireTimedPrompt(schedule, type, prompt, { awaitDispatch = false, reserved = false } = {}) {
743
+ logSchedule(`firing ${schedule.name} (${type})\n`);
744
+ const refusal = reserved ? null : this.fireRefusal(schedule.name);
745
+ if (refusal) {
746
+ this.recordRefusal(schedule, refusal);
654
747
  return false;
655
748
  }
656
749
  if (type === 'interactive' && this.injectIntoLead(schedule, type, prompt)) return true;
657
- this.running.add(schedule.name);
750
+ if (!reserved) this.running.add(schedule.name);
658
751
  // Cron (recurring) keeps the fire-and-forget contract: return truthy now
659
752
  // and swallow async failures. A one-shot (awaitDispatch) instead awaits
660
753
  // the dispatch so a rejected run propagates and the caller leaves the
661
754
  // entry pending for retry instead of retiring it.
662
- const dispatch = this.dispatchScheduleRun(schedule, prompt, awaitDispatch);
755
+ const run = this.dispatchScheduleRun(schedule, prompt, awaitDispatch);
756
+ const dispatch = reserved ? run : run.finally(() => this.running.delete(schedule.name));
663
757
  if (awaitDispatch) return await dispatch;
664
758
  return true;
665
759
  }
@@ -688,12 +782,14 @@ ${prompt}`;
688
782
  * callers and resolves false (after a logged notice) for fire-and-forget
689
783
  * cron fires. */
690
784
  dispatchScheduleRun(schedule, prompt, awaitDispatch) {
691
- const run = this.scheduleRunner
692
- ? this.scheduleRunner(schedule, { prompt })
693
- : Promise.reject(new Error('schedule runner not configured'));
694
- return run
785
+ // Called inside a then, so a runner that throws synchronously rejects
786
+ // like one that rejects, and the caller's cleanup still runs.
787
+ return Promise.resolve()
788
+ .then(() => {
789
+ if (!this.scheduleRunner) throw new Error('schedule runner not configured');
790
+ return this.scheduleRunner(schedule, { prompt });
791
+ })
695
792
  .then((outcome) => {
696
- this.running.delete(schedule.name);
697
793
  // The run path found this schedule's previous run still going (a
698
794
  // "run now" from the app) or backing off after failures in a row; it
699
795
  // already persisted the skip.
@@ -706,7 +802,6 @@ ${prompt}`;
706
802
  return true;
707
803
  })
708
804
  .catch((err) => {
709
- this.running.delete(schedule.name);
710
805
  logSchedule(`${schedule.name} LLM error: ${err.message}\n`);
711
806
  // The cron fire-and-forget contract swallowed failures entirely;
712
807
  // log them so a failed scheduled run is never
@@ -15,6 +15,7 @@ import { ensurePgInstance, withSchemaBootstrapLock } from '../../memory/lib/pg/a
15
15
  import { createPgSchemaDb } from '../../shared/pg-schema-db.mjs';
16
16
  import { resolvePluginData } from '../../shared/plugin-paths.mjs';
17
17
  import { ROUTINE_LIMITS } from '../../shared/routine-limits.mjs';
18
+ import { scheduleNameTakenError } from './schedule-errors.mjs';
18
19
 
19
20
  const SCHEMA = 'scheduler';
20
21
 
@@ -195,6 +196,54 @@ export async function upsertSchedule(def, { dataDir } = {}) {
195
196
  return rowToDef(rows[0]);
196
197
  }
197
198
 
199
+ /**
200
+ * Rename schedule `oldName` to `def.name` in one UPDATE of the primary key,
201
+ * applying the edited settings. Run history (last_* stamps, failure streak,
202
+ * undelivered results), status and the next_fire_at cursor (a deferral, or
203
+ * none for a finished one-shot) stay on the row. When `timingChanged`, the row
204
+ * is re-armed the way `upsertSchedule` re-arms a redefinition: a one-shot's
205
+ * cursor becomes its `when_at`, a recurring one's is cleared for the scheduler
206
+ * to compute. Resolves null when `oldName` is gone; an occupied `def.name`
207
+ * rejects and changes nothing.
208
+ */
209
+ export async function renameSchedule(oldName, def, { timingChanged = false, dataDir } = {}) {
210
+ const db = await getDb(dataDir);
211
+ try {
212
+ const { rows } = await db.query(
213
+ `UPDATE scheduler.schedules SET
214
+ name = $2, description = $3, when_at = $4, when_cron = $5, timezone = $6, cwd = $7,
215
+ attachments = $8, prompt = $9, enabled = $10, owner_agent_id = $11,
216
+ next_fire_at = CASE WHEN $12::boolean THEN $4::timestamptz ELSE next_fire_at END,
217
+ status = CASE WHEN $12::boolean THEN 'active' ELSE status END,
218
+ deferred_until = CASE WHEN $12::boolean THEN NULL ELSE deferred_until END,
219
+ skipped_until = CASE WHEN $12::boolean THEN NULL ELSE skipped_until END,
220
+ last_fired_at = CASE WHEN $12::boolean THEN NULL ELSE last_fired_at END,
221
+ last_scheduled_at = CASE WHEN $12::boolean THEN NULL ELSE last_scheduled_at END,
222
+ updated_at = now()
223
+ WHERE name = $1
224
+ RETURNING ${COLS}`,
225
+ [
226
+ oldName,
227
+ def.name,
228
+ def.description ?? '',
229
+ def.whenAt ?? null,
230
+ def.whenCron ?? null,
231
+ def.timezone ?? null,
232
+ def.cwd ?? null,
233
+ def.attachments ? JSON.stringify(def.attachments) : null,
234
+ def.prompt,
235
+ def.enabled ?? true,
236
+ def.ownerAgentId || 'lead',
237
+ timingChanged === true,
238
+ ]
239
+ );
240
+ return rowToDef(rows[0]);
241
+ } catch (error) {
242
+ if (error?.code === '23505') throw scheduleNameTakenError(def.name);
243
+ throw error;
244
+ }
245
+ }
246
+
198
247
  export async function deleteSchedule(name, { dataDir } = {}) {
199
248
  const db = await getDb(dataDir);
200
249
  const { rowCount } = await db.query(`DELETE FROM scheduler.schedules WHERE name = $1`, [name]);
@@ -3,6 +3,8 @@
3
3
  // is deleted with Discord/Telegram; only the automation lifecycle remains.
4
4
  // Lifecycle functions are threaded in as a bag of lazy getters so the module
5
5
  // reads the live file-level references at call time.
6
+ import { scheduleErrorFields } from './schedule-errors.mjs';
7
+
6
8
  function createToolDispatch({ isChannelsDegraded, lifecycle }) {
7
9
  const {
8
10
  getChannelBridgeActive,
@@ -13,6 +15,7 @@ function createToolDispatch({ isChannelsDegraded, lifecycle }) {
13
15
  startChannelBridge,
14
16
  stopOwnedRuntime,
15
17
  reloadRuntimeConfig,
18
+ renameSchedule,
16
19
  } = lifecycle;
17
20
 
18
21
  async function handleToolCall(name, args, _signal) {
@@ -82,6 +85,14 @@ function createToolDispatch({ isChannelsDegraded, lifecycle }) {
82
85
  };
83
86
  break;
84
87
  }
88
+ // The scheduler's owner moves a schedule row only while no run of it
89
+ // can start or be in flight (Scheduler.renameLocked). Resolves the
90
+ // renamed row as JSON text; a refusal is an isError result.
91
+ case 'rename_schedule': {
92
+ const saved = await renameSchedule(args.name, args.def, { timingChanged: args.timingChanged === true });
93
+ result = { content: [{ type: 'text', text: JSON.stringify(saved) }] };
94
+ break;
95
+ }
85
96
  // memory — handled by memory-service.mjs MCP
86
97
  default:
87
98
  result = {
@@ -94,6 +105,8 @@ function createToolDispatch({ isChannelsDegraded, lifecycle }) {
94
105
  result = {
95
106
  content: [{ type: 'text', text: `${name} failed: ${msg}` }],
96
107
  isError: true,
108
+ // A schedule refusal keeps its code and public name; nothing else crosses.
109
+ ...scheduleErrorFields(err),
97
110
  };
98
111
  }
99
112
  return result;
@@ -7,6 +7,7 @@ import { whisperThreadCount } from './voice-transcription.mjs';
7
7
  import { ensureReady, stopVoiceWhisperServer } from './whisper-server.mjs';
8
8
  import { ensurePrivateRuntimeRoot, resolveRuntimeRoot } from '../../shared/runtime-root.mjs';
9
9
  import { Scheduler } from './scheduler.mjs';
10
+ import { renameSchedule as renameScheduleRow } from './schedules-db.mjs';
10
11
  import { hasPending as dispatchHasPending } from '../../agent/orchestrator/dispatch-persist.mjs';
11
12
  import { stripSoftWarns } from '../../agent/orchestrator/tool-loop-guard.mjs';
12
13
  import { JsonStateFile } from './state-file.mjs';
@@ -264,6 +265,14 @@ const { handleToolCall } = createToolDispatch({
264
265
  startChannelBridge: () => start({ messaging: true }),
265
266
  stopOwnedRuntime,
266
267
  reloadRuntimeConfig,
268
+ // Rename and re-arm under one reservation: the scheduler is reloaded from
269
+ // the renamed row (timers and one-shots of the old name cleared) before
270
+ // the name is released for fires.
271
+ renameSchedule: (name, def, opts) =>
272
+ scheduler.renameLocked(name, () => renameScheduleRow(name, def, opts), {
273
+ newName: def.name,
274
+ reload: reloadRuntimeConfig,
275
+ }),
267
276
  },
268
277
  });
269
278
  function ensureConfigWatcher() {
@@ -39,6 +39,15 @@ function spawnPowerShell(script) {
39
39
  );
40
40
  }
41
41
 
42
+ // Resolves once the host process is gone. A signal-terminated child has exitCode null but signalCode set,
43
+ // and its 'close' may already have been emitted, so both are checked before waiting for the event.
44
+ export function hostExitSignal(child) {
45
+ return new Promise((done) => {
46
+ if (child.exitCode !== null || child.signalCode !== null) done();
47
+ else child.once('close', () => done());
48
+ });
49
+ }
50
+
42
51
  async function callMicrosoftOfficeOnce(payload, { timeoutMs = DEFAULT_TIMEOUT_MS, signal = null } = {}) {
43
52
  return await new Promise((resolve) => {
44
53
  const child = spawnPowerShell(HOST_SCRIPT);
@@ -65,13 +74,20 @@ async function callMicrosoftOfficeOnce(payload, { timeoutMs = DEFAULT_TIMEOUT_MS
65
74
  },
66
75
  };
67
76
  void drainSessionClient(client, error).then((cleanup) => {
68
- finish({
77
+ const result = {
69
78
  ok: false,
70
79
  cancelled,
71
80
  error: officeCleanupError(error, cleanup),
72
81
  operationMayHaveCompleted: true,
73
82
  cleanup,
83
+ };
84
+ // Settles when the host process is gone, which may be after this result when the
85
+ // grace period lapsed: callers that own files the host holds open wait on it.
86
+ // Non-enumerable so the result still serializes unchanged.
87
+ Object.defineProperty(result, 'hostExit', {
88
+ value: hostExitSignal(child),
74
89
  });
90
+ finish(result);
75
91
  });
76
92
  };
77
93
  const onAbort = () => beginCleanup('Microsoft Office operation was cancelled', true);
@@ -7545,6 +7545,52 @@ function Open-ValidationDocument($app, [string]$format, [string]$path) {
7545
7545
  }
7546
7546
  }
7547
7547
 
7548
+ $script:PreviewPasswordMessage = 'This file is password-protected. (OFFICE_PREVIEW_PASSWORD_PROTECTED)'
7549
+
7550
+ # An encrypted Office Open XML package is an OLE container (D0 CF 11 E0), not a ZIP. Office would stop to ask
7551
+ # for the password, and a hidden application cannot show the question, so the request is refused up front.
7552
+ function Assert-PreviewNotEncrypted([string]$path) {
7553
+ $extension = [System.IO.Path]::GetExtension($path).TrimStart('.').ToLowerInvariant()
7554
+ if (@('docx', 'docm', 'dotx', 'xlsx', 'xlsm', 'pptx', 'pptm') -notcontains $extension) { return }
7555
+ $header = New-Object byte[] 4
7556
+ $stream = [System.IO.File]::Open($path, 'Open', 'Read', 'ReadWrite')
7557
+ try { $read = $stream.Read($header, 0, 4) } finally { $stream.Dispose() }
7558
+ if ($read -eq 4 -and $header[0] -eq 0xD0 -and $header[1] -eq 0xCF -and $header[2] -eq 0x11 -and $header[3] -eq 0xE0) {
7559
+ throw $script:PreviewPasswordMessage
7560
+ }
7561
+ }
7562
+
7563
+ $script:PreviewWrongPasswordMessage = "That password didn't work. (OFFICE_PREVIEW_WRONG_PASSWORD)"
7564
+
7565
+ # The read-only open of the file preview. Word and Excel get the user's password when the request carries one
7566
+ # (Documents.Open PasswordDocument, Workbooks.Open Password), else a placeholder, so a protected legacy file fails
7567
+ # at once instead of waiting on a prompt nobody can see. Word's "password is incorrect" (0x800A1520) becomes the
7568
+ # plain message: the protected one without a password, the wrong-password one with it. Presentations.Open takes no
7569
+ # password, so PowerPoint never gets one. Links are not updated, macros stay off and DisplayAlerts is off
7570
+ # (New-HiddenApplication). Automation opens do not go through Protected View.
7571
+ function Open-PreviewDocument($app, [string]$format, [string]$path, [string]$password) {
7572
+ $placeholder = 'mixdog-preview-no-password'
7573
+ $openPassword = if ($password) { $password } else { $placeholder }
7574
+ try {
7575
+ switch ($format) {
7576
+ 'docx' { return $app.Documents.Open($path, $false, $true, $false, $openPassword, $placeholder, $false, $placeholder, $placeholder) }
7577
+ # No WriteResPassword: Excel rejects one for a workbook without write reservation (0x800A03EC), and a
7578
+ # read-only open never asks for it.
7579
+ 'xlsx' { return $app.Workbooks.Open($path, 0, $true, [Type]::Missing, $openPassword, [Type]::Missing, $true) }
7580
+ 'pptx' { return $app.Presentations.Open($path, $true, $true, $false) }
7581
+ }
7582
+ }
7583
+ catch {
7584
+ $inner = $_.Exception
7585
+ while ($null -ne $inner.InnerException) { $inner = $inner.InnerException }
7586
+ if ($format -eq 'docx' -and $inner.HResult -eq 0x800A1520) {
7587
+ if ($password) { throw $script:PreviewWrongPasswordMessage }
7588
+ throw $script:PreviewPasswordMessage
7589
+ }
7590
+ throw
7591
+ }
7592
+ }
7593
+
7548
7594
  function Validate-NativeDocument([string]$path, [string]$format) {
7549
7595
  $validationApp = $null
7550
7596
  $validationDocument = $null
@@ -7757,9 +7803,13 @@ try {
7757
7803
  $live = $true
7758
7804
  }
7759
7805
  else {
7806
+ if ($payload.readOnly -eq $true) { Assert-PreviewNotEncrypted $path }
7760
7807
  $app = New-HiddenApplication $format $progId
7761
7808
  $createdApp = $true
7762
- $document = Open-BackgroundDocument $app $format $path
7809
+ # A read-only request (the file preview) opens the document read-only, with no link updates and no
7810
+ # recalculation, and never waits on a password prompt. The close below discards, so nothing can be
7811
+ # written back.
7812
+ $document = if ($payload.readOnly -eq $true) { Open-PreviewDocument $app $format $path ([string]$payload.password) } else { Open-BackgroundDocument $app $format $path }
7763
7813
  }
7764
7814
 
7765
7815
  switch ([string]$payload.action) {