discoclaw 0.7.0 → 0.8.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.
Files changed (121) hide show
  1. package/.context/runtime.md +44 -0
  2. package/.env.example +2 -0
  3. package/.env.example.full +34 -4
  4. package/dist/cli/dashboard.js +3 -1
  5. package/dist/cli/dashboard.test.js +6 -0
  6. package/dist/config.js +1 -0
  7. package/dist/cron/cron-sync-coordinator.test.js +5 -4
  8. package/dist/cron/cron-sync.js +115 -6
  9. package/dist/cron/cron-sync.test.js +112 -1
  10. package/dist/cron/cron-tag-map-watcher.test.js +1 -1
  11. package/dist/cron/executor.js +8 -2
  12. package/dist/cron/forum-sync.js +93 -6
  13. package/dist/cron/forum-sync.test.js +9 -0
  14. package/dist/cron/run-stats.js +66 -1
  15. package/dist/cron/run-stats.test.js +7 -7
  16. package/dist/cron/scheduler.js +8 -0
  17. package/dist/dashboard/api/snapshot.test.js +3 -0
  18. package/dist/dashboard/page.js +38 -0
  19. package/dist/dashboard/page.test.js +6 -0
  20. package/dist/dashboard/server.js +45 -2
  21. package/dist/dashboard/server.test.js +136 -0
  22. package/dist/dashboard/shipped-entrypoint.test.js +2 -0
  23. package/dist/discord/action-categories.js +1 -0
  24. package/dist/discord/actions-config.js +34 -0
  25. package/dist/discord/actions-config.test.js +51 -5
  26. package/dist/discord/actions-crons.js +213 -121
  27. package/dist/discord/actions-crons.test.js +44 -16
  28. package/dist/discord/actions-forge.js +221 -12
  29. package/dist/discord/actions-forge.test.js +286 -3
  30. package/dist/discord/actions-loop.js +41 -4
  31. package/dist/discord/actions-loop.test.js +22 -0
  32. package/dist/discord/actions-plan.js +1 -1
  33. package/dist/discord/actions-spawn.js +14 -3
  34. package/dist/discord/actions.js +36 -2
  35. package/dist/discord/actions.test.js +125 -2
  36. package/dist/discord/deferred-runner.js +6 -1
  37. package/dist/discord/forge-auto-implement.js +9 -1
  38. package/dist/discord/forge-auto-implement.test.js +17 -0
  39. package/dist/discord/forge-commands.js +1531 -139
  40. package/dist/discord/forge-commands.test.js +1352 -25
  41. package/dist/discord/forge-plan-registry.js +202 -20
  42. package/dist/discord/forge-plan-registry.test.js +193 -1
  43. package/dist/discord/health-command.js +6 -0
  44. package/dist/discord/long-run-watchdog-notice.js +44 -0
  45. package/dist/discord/long-run-watchdog-notice.test.js +89 -0
  46. package/dist/discord/long-run-watchdog.js +50 -0
  47. package/dist/discord/long-run-watchdog.test.js +129 -0
  48. package/dist/discord/message-coordinator.followup-lifecycle.test.js +306 -0
  49. package/dist/discord/message-coordinator.js +250 -61
  50. package/dist/discord/message-coordinator.reaction-cleanup.test.js +1 -1
  51. package/dist/discord/message-coordinator.run-state.test.js +178 -0
  52. package/dist/discord/message-coordinator.test.js +191 -0
  53. package/dist/discord/models-command.js +1 -1
  54. package/dist/discord/models-command.test.js +3 -0
  55. package/dist/discord/output-common.js +54 -0
  56. package/dist/discord/pinned-message-utils.js +67 -0
  57. package/dist/discord/pinned-message-utils.test.js +35 -0
  58. package/dist/discord/plan-manager.js +26 -15
  59. package/dist/discord/plan-manager.test.js +32 -20
  60. package/dist/discord/prompt-common.js +22 -4
  61. package/dist/discord/prompt-common.test.js +58 -1
  62. package/dist/discord/reaction-handler.js +159 -37
  63. package/dist/discord/reaction-handler.test.js +257 -0
  64. package/dist/discord/run-state-guidance.js +12 -0
  65. package/dist/discord/run-state-guidance.test.js +24 -0
  66. package/dist/discord/status-channel.js +9 -0
  67. package/dist/discord/status-channel.test.js +32 -0
  68. package/dist/discord/thread-context.js +16 -5
  69. package/dist/discord/thread-context.test.js +19 -0
  70. package/dist/discord/user-errors.test.js +16 -0
  71. package/dist/discord-followup.test.js +63 -13
  72. package/dist/discord.prompt-context.test.js +35 -0
  73. package/dist/forge-phase.js +37 -0
  74. package/dist/health/config-doctor.js +202 -3
  75. package/dist/health/config-doctor.test.js +178 -2
  76. package/dist/health/startup-healing.js +52 -7
  77. package/dist/health/startup-healing.test.js +21 -15
  78. package/dist/index.js +56 -23
  79. package/dist/index.post-connect.js +2 -0
  80. package/dist/instructions/tracked-tools.js +133 -12
  81. package/dist/instructions/tracked-tools.test.js +116 -1
  82. package/dist/observability/metrics.js +2 -0
  83. package/dist/observability/metrics.test.js +6 -0
  84. package/dist/pipeline/engine.js +50 -3
  85. package/dist/pipeline/engine.test.js +52 -6
  86. package/dist/runtime/cli-adapter.js +90 -9
  87. package/dist/runtime/cli-adapter.test.js +146 -0
  88. package/dist/runtime/cli-shared.js +23 -0
  89. package/dist/runtime/cli-shared.test.js +66 -1
  90. package/dist/runtime/cli-strategy.js +90 -2
  91. package/dist/runtime/cli-strategy.test.js +54 -0
  92. package/dist/runtime/codex-app-server.js +1706 -0
  93. package/dist/runtime/codex-app-server.test.js +2220 -0
  94. package/dist/runtime/codex-cli.js +192 -4
  95. package/dist/runtime/codex-cli.test.js +630 -20
  96. package/dist/runtime/global-supervisor.js +14 -3
  97. package/dist/runtime/global-supervisor.test.js +28 -0
  98. package/dist/runtime/model-tiers.js +38 -0
  99. package/dist/runtime/model-tiers.test.js +28 -1
  100. package/dist/runtime/openai-auth.js +5 -0
  101. package/dist/runtime/openai-compat.js +57 -32
  102. package/dist/runtime/openai-compat.test.js +1 -1
  103. package/dist/runtime/openai-tool-exec.js +120 -68
  104. package/dist/runtime/openai-tool-exec.test.js +247 -8
  105. package/dist/runtime/runtime-failure.js +56 -1
  106. package/dist/runtime/runtime-failure.test.js +51 -1
  107. package/dist/runtime/strategies/codex-strategy.js +90 -28
  108. package/dist/runtime/strategies/codex-strategy.test.js +147 -0
  109. package/dist/runtime/tool-capabilities.js +95 -0
  110. package/dist/runtime/tool-capabilities.test.js +75 -1
  111. package/dist/runtime/tools/path-security.js +42 -17
  112. package/dist/runtime/tools/path-security.test.js +81 -0
  113. package/dist/tasks/task-sync-cli.js +1 -1
  114. package/dist/tasks/task-sync-cli.test.js +13 -0
  115. package/dist/tasks/task-sync-engine.test.js +81 -19
  116. package/dist/tasks/task-sync-phase-apply.js +12 -0
  117. package/dist/tasks/task-sync-reconcile.js +14 -5
  118. package/dist/workspace-bootstrap.js +59 -6
  119. package/docs/official-docs.md +1 -1
  120. package/package.json +2 -1
  121. package/templates/instructions/SYSTEM_DEFAULTS.md +19 -0
@@ -77,6 +77,50 @@ Shutdown: `killAllSubprocesses()` from `cli-adapter.ts` kills all tracked subpro
77
77
  - `CLAUDE_OUTPUT_FORMAT=stream-json` (preferred; DiscoClaw parses JSONL and streams text)
78
78
  - `CLAUDE_OUTPUT_FORMAT=text` (fallback if your local CLI doesn't support stream-json)
79
79
 
80
+ ## Codex CLI Runtime
81
+
82
+ - Adapter: `src/runtime/codex-cli.ts` (thin wrapper around `cli-adapter.ts` + `strategies/codex-strategy.ts`)
83
+ - Default transport:
84
+ - The default Codex path is `codex exec` / `codex exec resume`.
85
+ - DiscoClaw currently prefers the CLI route for reliability, especially in forge flows.
86
+ - Optional native transport:
87
+ - When explicitly enabled, DiscoClaw can use the Codex app-server websocket for `thread/start`, `turn/start`, streaming output, `turn/steer`, and `turn/interrupt`.
88
+ - The native path still uses Codex's local app-server auth/session state, so no public API key is required.
89
+ - Activation and fallback:
90
+ - Native invoke is opt-in via `CODEX_APP_SERVER_NATIVE=1`.
91
+ - Native invoke also requires `CODEX_APP_SERVER_URL` to point at a reachable websocket endpoint.
92
+ - If the native flag is off, the URL is unset, or the turn hits an images / non-default `cwd` bypass gate, DiscoClaw uses the `codex exec` / `codex exec resume` transport instead.
93
+ - If native invoke is selected but the websocket connection cannot be established, DiscoClaw falls back to `codex exec` for that turn rather than failing closed.
94
+ - Env vars:
95
+ | Var | Default | Purpose |
96
+ |-----|---------|---------|
97
+ | `CODEX_APP_SERVER_NATIVE` | `0` | Enables the app-server-native invoke path for eligible turns. |
98
+ | `CODEX_APP_SERVER_URL` | *(unset)* | Codex app-server websocket URL (for example `ws://127.0.0.1:4321`) used by the native path. |
99
+ - Native bypass gates:
100
+ - **Images:** turns with `images` bypass native invoke and stay on `codex exec`, because image parity is not guaranteed on the app-server path.
101
+ - **Non-default `cwd`:** turns whose `cwd` differs from the process working directory bypass native invoke and stay on `codex exec`, which is the only path that can shape the subprocess working directory per turn.
102
+ - **`addDirs`:** native invoke passes extra readable roots through to the app-server sandbox as additional `readableRoots`, so standard Discord turns can still stay on the websocket path.
103
+ - **Connection failure:** if the runtime cannot connect/initialize against the app-server websocket, it immediately drops back to `codex exec` for that invocation.
104
+ - Capability declaration:
105
+ - When native invoke is enabled and the app-server is configured, the runtime adds `mid_turn_steering` and exposes `RuntimeAdapter.steer()` + `RuntimeAdapter.interrupt()`.
106
+ - When native invoke is inactive, the Codex adapter behaves like the legacy CLI runtime: no control methods and no extra capability.
107
+ - Lifecycle and streaming:
108
+ - For native turns, the runtime creates or reuses a thread with `thread/start`, starts the turn with `turn/start`, and tracks the active turn directly from the app-server response/notifications.
109
+ - Streaming reply text comes from agent-message delta/completed notifications; tool progress comes from item start/completion notifications; terminal turn notifications clear the active turn and finish the stream.
110
+ - Because the runtime receives the live `turnId` from the app-server itself, steering and interrupt can target the active turn reliably instead of depending on `codex exec --json` to surface it mid-turn.
111
+ - Session semantics:
112
+ - Reusing the same `sessionKey` reuses the same native `threadId`, so repeated turns continue the same Codex thread.
113
+ - Omitting `sessionKey` creates an ephemeral native thread for that invocation only.
114
+ - `disableSessions` strips `sessionKey` before dispatch, so native turns become ephemeral and never reuse a prior thread.
115
+ - Steering / interrupt semantics:
116
+ - `steer(sessionKey, message)` is best-effort and returns `false` instead of throwing when there is no tracked active turn or the app-server request fails.
117
+ - Successful steering sends `turn/steer` with `threadId` plus `expectedTurnId`; if the server returns a replacement `turnId`, the runtime updates its active-turn pointer.
118
+ - `interrupt(sessionKey)` is also best-effort and sends `turn/interrupt` with the active `threadId` + `turnId`, then clears local active-turn state.
119
+ - Tradeoffs vs `codex exec`:
120
+ - Native invoke is useful when live control matters: it owns thread/turn lifecycle directly, exposes the active `turnId`, and supports steer/interrupt semantics.
121
+ - `codex exec` remains the default and preferred path for general reliability, plus any turns that need unsupported invocation shapes or when the app-server is unavailable.
122
+ - Keeping both paths preserves local Codex auth and session behavior without forcing the app-server path on day-to-day usage.
123
+
80
124
  ## Gemini CLI Runtime
81
125
 
82
126
  - Adapter: `src/runtime/gemini-cli.ts` (thin wrapper around `cli-adapter.ts` + `strategies/gemini-strategy.ts`)
package/.env.example CHANGED
@@ -91,6 +91,8 @@ DISCORD_GUILD_ID=
91
91
  #DISCOCLAW_CODEX_ITEM_TYPE_DEBUG=1
92
92
  # Log each Discord preview line decision (allowed/suppressed + rendered line) to journald:
93
93
  #DISCOCLAW_DEBUG_STREAM_PREVIEW_LINES=1
94
+ # Discord action auto-follow-up stall timeout (ms). Controls when pending follow-up turns are treated as stalled.
95
+ #DISCOCLAW_ACTION_FOLLOWUP_TIMEOUT_MS=30000
94
96
 
95
97
  # [DEPRECATED] Model configuration has moved to models.json (managed via !models commands).
96
98
  # RUNTIME_MODEL is still read as a fallback when models.json is missing, but new deployments
package/.env.example.full CHANGED
@@ -437,6 +437,8 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
437
437
  #DISCOCLAW_MAX_CONCURRENT_INVOCATIONS=3
438
438
  # Max depth for chained action follow-ups (e.g. defer → action → response). 0 = disabled.
439
439
  #DISCOCLAW_ACTION_FOLLOWUP_DEPTH=3
440
+ # Timeout (ms) before a pending Discord action auto-follow-up is marked stalled. Default: 30000.
441
+ #DISCOCLAW_ACTION_FOLLOWUP_TIMEOUT_MS=30000
440
442
  # Timeout for runtime invocations (ms).
441
443
  #RUNTIME_TIMEOUT_MS=1800000
442
444
  # Global runtime supervisor wrapper (off by default; preserves legacy behavior).
@@ -524,9 +526,13 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
524
526
  # ----------------------------------------------------------
525
527
  # Route the forge drafter to a non-Claude runtime.
526
528
  # Valid values: "codex" (Codex CLI), "openai" (OpenAI-compatible HTTP API), "openrouter" (OpenRouter).
529
+ # Note: forge currently forces Codex phases onto codex exec by default instead of
530
+ # the native app-server path.
527
531
  #FORGE_DRAFTER_RUNTIME=
528
532
  # Route the forge auditor to a non-Claude runtime.
529
533
  # Valid values: "codex" (Codex CLI), "openai" (OpenAI-compatible HTTP API), "openrouter" (OpenRouter).
534
+ # Note: forge currently forces Codex phases onto codex exec by default instead of
535
+ # the native app-server path.
530
536
  #FORGE_AUDITOR_RUNTIME=
531
537
 
532
538
  # --- Codex CLI adapter ---
@@ -534,6 +540,20 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
534
540
  #CODEX_BIN=codex
535
541
  # Optional: isolate Codex state/sessions from ~/.codex (helps avoid stale rollout DB issues).
536
542
  #CODEX_HOME=/absolute/path/to/.codex-home-discoclaw
543
+ # Optional: Codex app-server websocket URL for the native app-server invoke path.
544
+ # When combined with CODEX_APP_SERVER_NATIVE=1, DiscoClaw uses the websocket for
545
+ # thread/turn lifecycle, streaming output, steering, and interrupts. Unset keeps
546
+ # the native path dormant.
547
+ #CODEX_APP_SERVER_URL=ws://127.0.0.1:4321
548
+ # Enable the optional native Codex app-server transport.
549
+ # Set to 1 to allow eligible Codex turns to use the websocket path. The CLI route
550
+ # remains the recommended default for normal ops because it has been more reliable.
551
+ # Image turns, non-default cwd turns, and websocket bootstrap failures fall back
552
+ # to codex exec automatically. Forge currently disables the native app-server
553
+ # route for Codex phases by default and uses codex exec instead; CODEX_APP_SERVER_NATIVE=1
554
+ # still applies to other eligible Codex turns. Extra readable roots from addDirs
555
+ # are passed through to the app-server sandbox.
556
+ #CODEX_APP_SERVER_NATIVE=0
537
557
  # Default model for the Codex CLI adapter. Used when FORGE_AUDITOR_MODEL is not set.
538
558
  #CODEX_MODEL=gpt-5.4
539
559
  # WARNING: disables Codex approval prompts and sandbox protections (full-access mode).
@@ -593,8 +613,17 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
593
613
  # ----------------------------------------------------------
594
614
  # Image generation
595
615
  # ----------------------------------------------------------
596
- # Master switch enables the imagegen Discord action category (default: off).
597
- # When enabled, the AI can generate images via action blocks using OpenAI or Gemini.
616
+ # Discoverable by default on the normal user-facing help/manual surfaces:
617
+ # `!models`, `!models help`, and the normal manual message/follow-up action path
618
+ # can advertise `imagegen` before setup is complete.
619
+ # `!models` shows `imagegen` as setup-required when unconfigured; `!models help`
620
+ # points operators back to env setup because `!models set` does not configure imagegen.
621
+ # Master switch for actual image generation execution (default: off).
622
+ # When set to 1 and a provider key is configured, the AI can generate images via
623
+ # action blocks using OpenAI or Gemini. When left at 0, normal manual/follow-up
624
+ # invocations return a setup walkthrough instead of generating an image.
625
+ # Reaction/deferred advertisement rules remain on their existing flag-driven contract,
626
+ # and loop execution does not use the manual setup walkthrough path.
598
627
  DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=0
599
628
  # API key for Gemini image generation (Imagen and native Gemini models).
600
629
  # Leave unset to use OpenAI only.
@@ -606,8 +635,9 @@ DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=0
606
635
  # Gemini native models (text+image in one call): gemini-3.1-flash-image-preview, gemini-3-pro-image-preview
607
636
  #IMAGEGEN_DEFAULT_MODEL=
608
637
  # Note: OpenAI image generation reuses OPENAI_API_KEY (documented above in the
609
- # OpenAI-compatible HTTP adapter section). When DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=1,
610
- # at least one of OPENAI_API_KEY or IMAGEGEN_GEMINI_API_KEY must be set.
638
+ # OpenAI-compatible HTTP adapter section). Actual generation stays disabled until
639
+ # DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=1 and at least one of OPENAI_API_KEY or
640
+ # IMAGEGEN_GEMINI_API_KEY is set.
611
641
 
612
642
  # ----------------------------------------------------------
613
643
  # Voice (STT/TTS) — join voice channels, listen and respond
@@ -7,7 +7,7 @@ import { config as loadDotenv } from 'dotenv';
7
7
  import { getLocalVersion, isNpmManaged } from '../npm-managed.js';
8
8
  import { isModelTier, listKnownModelValues } from '../runtime/model-tiers.js';
9
9
  import { getGitHash } from '../version.js';
10
- import { applyFixes, inspect, KNOWN_RUNTIMES, loadDoctorContext } from '../health/config-doctor.js';
10
+ import { applyFixes, inspect, KNOWN_RUNTIMES, loadDoctorContext, updateEnvKey } from '../health/config-doctor.js';
11
11
  import { detectMcpServers, validateMcpEnvInterpolation, validateMcpServerEnv, validateMcpServerNames, } from '../mcp-detect.js';
12
12
  import { DEFAULTS as MODEL_DEFAULTS, saveModelConfig } from '../model-config.js';
13
13
  import { saveOverrides } from '../runtime-overrides.js';
@@ -59,6 +59,7 @@ function createDefaultDeps() {
59
59
  loadDoctorContext,
60
60
  saveModelConfig,
61
61
  saveOverrides,
62
+ updateEnvKey,
62
63
  runCommand(cmd, args) {
63
64
  return new Promise((resolve) => {
64
65
  execFile(cmd, args, { timeout: 15_000 }, (err, stdout, stderr) => {
@@ -311,6 +312,7 @@ export async function collectDashboardSnapshot(opts = {}, deps = createDefaultDe
311
312
  },
312
313
  mcpStatus: toBootReportMcpStatus(mcpDetectResult),
313
314
  mcpWarnings,
315
+ primaryRuntime: normalizeRuntimeName(ctx.env.PRIMARY_RUNTIME) ?? 'claude',
314
316
  };
315
317
  }
316
318
  export function renderDashboard(snapshot, detail = '') {
@@ -22,6 +22,7 @@ function makeConfigPaths(cwd, dataDir = path.join(cwd, 'data')) {
22
22
  function makeDoctorContext(overrides = {}) {
23
23
  return {
24
24
  cwd: '/repo',
25
+ workspaceCwd: '/repo/workspace',
25
26
  installMode: 'source',
26
27
  env: {
27
28
  DISCOCLAW_SERVICE_NAME: 'discoclaw-beta',
@@ -103,6 +104,7 @@ function makeDashboardDeps(overrides = {}) {
103
104
  loadDoctorContext: vi.fn(async () => makeDoctorContext()),
104
105
  saveModelConfig: vi.fn(async () => undefined),
105
106
  saveOverrides: vi.fn(async () => undefined),
107
+ updateEnvKey: vi.fn(async () => undefined),
106
108
  runCommand: vi.fn(async () => ({
107
109
  stdout: 'Active: active (running)\n',
108
110
  stderr: '',
@@ -148,6 +150,7 @@ function makeSnapshot(overrides = {}) {
148
150
  },
149
151
  mcpStatus: { status: 'missing' },
150
152
  mcpWarnings: 0,
153
+ primaryRuntime: 'claude',
151
154
  ...overrides,
152
155
  };
153
156
  }
@@ -270,6 +273,7 @@ describe('collectDashboardSnapshot', () => {
270
273
  loadDoctorContext: vi.fn(async () => ctx),
271
274
  saveModelConfig: vi.fn(async () => undefined),
272
275
  saveOverrides: vi.fn(async () => undefined),
276
+ updateEnvKey: vi.fn(async () => undefined),
273
277
  runCommand: vi.fn(async () => ({
274
278
  stdout: ' Active: active (running) since today\n',
275
279
  stderr: '',
@@ -287,6 +291,7 @@ describe('collectDashboardSnapshot', () => {
287
291
  expect(snapshot.doctorSummary).toBe('1 findings (errors=1, warnings=0, info=0)');
288
292
  expect(snapshot.version).toBe('1.2.3');
289
293
  expect(snapshot.gitHash).toBe('abc1234');
294
+ expect(snapshot.primaryRuntime).toBe('claude');
290
295
  expect(snapshot.roles).toEqual([
291
296
  'chat',
292
297
  'plan-run',
@@ -328,6 +333,7 @@ describe('collectDashboardSnapshot', () => {
328
333
  loadDoctorContext,
329
334
  saveModelConfig: vi.fn(async () => undefined),
330
335
  saveOverrides: vi.fn(async () => undefined),
336
+ updateEnvKey: vi.fn(async () => undefined),
331
337
  runCommand: vi.fn(async () => ({
332
338
  stdout: ' Active: active (running) since today\n',
333
339
  stderr: '',
package/dist/config.js CHANGED
@@ -481,6 +481,7 @@ export function parseConfig(env) {
481
481
  forgeAutoImplement: parseBoolean(env, 'FORGE_AUTO_IMPLEMENT', true),
482
482
  completionNotifyEnabled: parseBoolean(env, 'DISCOCLAW_COMPLETION_NOTIFY', true),
483
483
  completionNotifyThresholdMs: parseNonNegativeInt(env, 'DISCOCLAW_COMPLETION_NOTIFY_THRESHOLD_MS', 30000),
484
+ actionFollowupTimeoutMs: parseNonNegativeInt(env, 'DISCOCLAW_ACTION_FOLLOWUP_TIMEOUT_MS', 30000),
484
485
  openaiApiKey,
485
486
  openaiBaseUrl,
486
487
  openaiModel,
@@ -11,6 +11,7 @@ vi.mock('./cron-sync.js', () => ({
11
11
  statusMessagesUpdated: 1,
12
12
  promptMessagesCreated: 0,
13
13
  orphansDetected: 0,
14
+ projectionsRepaired: 0,
14
15
  })),
15
16
  }));
16
17
  import { reloadCronTagMapInPlace } from './tag-map.js';
@@ -41,7 +42,7 @@ describe('CronSyncCoordinator', () => {
41
42
  vi.resetAllMocks();
42
43
  mockReload.mockResolvedValue(2);
43
44
  mockRunCronSync.mockResolvedValue({
44
- tagsApplied: 1, namesUpdated: 0, statusMessagesUpdated: 1, promptMessagesCreated: 0, orphansDetected: 0,
45
+ tagsApplied: 1, namesUpdated: 0, statusMessagesUpdated: 1, promptMessagesCreated: 0, orphansDetected: 0, projectionsRepaired: 0,
45
46
  });
46
47
  });
47
48
  it('reloads tag map before sync when tagMapPath is set', async () => {
@@ -70,7 +71,7 @@ describe('CronSyncCoordinator', () => {
70
71
  it('coalesced concurrent sync returns null', async () => {
71
72
  let resolveSync;
72
73
  mockRunCronSync.mockImplementation(() => new Promise((resolve) => {
73
- resolveSync = () => resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0 });
74
+ resolveSync = () => resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0, projectionsRepaired: 0 });
74
75
  }));
75
76
  const coordinator = new CronSyncCoordinator(makeOpts());
76
77
  const first = coordinator.sync();
@@ -86,10 +87,10 @@ describe('CronSyncCoordinator', () => {
86
87
  mockRunCronSync.mockImplementation(() => new Promise((resolve) => {
87
88
  callCount++;
88
89
  if (callCount === 1) {
89
- resolveSync = () => resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0 });
90
+ resolveSync = () => resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0, projectionsRepaired: 0 });
90
91
  }
91
92
  else {
92
- resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0 });
93
+ resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0, projectionsRepaired: 0 });
93
94
  }
94
95
  }));
95
96
  const coordinator = new CronSyncCoordinator(makeOpts());
@@ -1,5 +1,5 @@
1
1
  import { EmbedBuilder } from 'discord.js';
2
- import { CADENCE_TAGS } from './run-stats.js';
2
+ import { CADENCE_TAGS, computeDefinitionHash } from './run-stats.js';
3
3
  import { detectCadence } from './cadence.js';
4
4
  import { autoTagCron, classifyCronModel } from './auto-tag.js';
5
5
  import { buildCronThreadName, ensureStatusMessage, resolveForumChannel } from './discord-sync.js';
@@ -25,7 +25,7 @@ export async function runCronSync(opts) {
25
25
  const forum = await resolveForumChannel(client, forumId);
26
26
  if (!forum) {
27
27
  log?.warn({ forumId }, 'cron-sync: forum not found');
28
- return { tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0 };
28
+ return { tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0, projectionsRepaired: 0 };
29
29
  }
30
30
  const tagMap = opts.tagMap;
31
31
  const purposeTags = purposeTagNames(tagMap);
@@ -34,6 +34,7 @@ export async function runCronSync(opts) {
34
34
  let statusMessagesUpdated = 0;
35
35
  let promptMessagesCreated = 0;
36
36
  let orphansDetected = 0;
37
+ let projectionsRepaired = 0;
37
38
  const asEditableCronThread = (value) => {
38
39
  if (!value || typeof value !== 'object')
39
40
  return null;
@@ -46,13 +47,15 @@ export async function runCronSync(opts) {
46
47
  return null;
47
48
  }
48
49
  const appliedTags = Array.isArray(t.appliedTags) ? t.appliedTags.filter((id) => typeof id === 'string') : undefined;
50
+ const sourceThread = value;
49
51
  return {
50
52
  id: t.id,
51
53
  parentId: t.parentId,
52
54
  name: t.name,
53
55
  appliedTags,
54
- edit: t.edit,
55
- setName: t.setName,
56
+ // Discord.js thread mutators read `this.client.rest`, so keep the live thread instance as `this`.
57
+ edit: t.edit.bind(sourceThread),
58
+ setName: t.setName.bind(sourceThread),
56
59
  };
57
60
  };
58
61
  // Get all active threads in the forum.
@@ -168,6 +171,15 @@ export async function runCronSync(opts) {
168
171
  try {
169
172
  await ensureStatusMessage(client, fullJob.threadId, fullJob.cronId, record, statsStore, { log });
170
173
  statusMessagesUpdated++;
174
+ // Update projection hash after successful status message sync.
175
+ const currentHash = computeDefinitionHash(record);
176
+ if (record.projectionHash !== currentHash || record.projectionStatus !== 'synced') {
177
+ await statsStore.upsertRecord(fullJob.cronId, fullJob.threadId, {
178
+ projectionStatus: 'synced',
179
+ projectionSyncedAt: new Date().toISOString(),
180
+ projectionHash: currentHash,
181
+ });
182
+ }
171
183
  }
172
184
  catch (err) {
173
185
  log?.warn({ err, jobId: job.id }, 'cron-sync:phase3 status message failed');
@@ -231,6 +243,103 @@ export async function runCronSync(opts) {
231
243
  log?.warn({ threadId: editableThread.id, name: editableThread.name }, 'cron-sync:phase4 orphan thread (no registered job)');
232
244
  }
233
245
  }
234
- log?.info({ tagsApplied, namesUpdated, statusMessagesUpdated, promptMessagesCreated, orphansDetected }, 'cron-sync: complete');
235
- return { tagsApplied, namesUpdated, statusMessagesUpdated, promptMessagesCreated, orphansDetected };
246
+ // Phase 5: Projection reconciliation repair missing/drifted/stale Discord projections
247
+ // from canonical local state. This ensures local records are the source of truth.
248
+ const allRecords = statsStore.getCanonicalDefinitions();
249
+ for (const [cronId, record] of Object.entries(allRecords)) {
250
+ const currentHash = computeDefinitionHash(record);
251
+ const status = record.projectionStatus;
252
+ const needsReconciliation = status === 'missing' ||
253
+ status === 'drifted' ||
254
+ status === 'pending-resync' ||
255
+ (record.projectionHash !== undefined && record.projectionHash !== currentHash);
256
+ if (!needsReconciliation)
257
+ continue;
258
+ if (status === 'missing') {
259
+ // Thread was deleted — recreate from canonical definition.
260
+ if (!record.channel || !record.prompt)
261
+ continue;
262
+ try {
263
+ const baseName = record.prompt.slice(0, 50).replace(/\n/g, ' ').trim() || cronId;
264
+ const cadence = record.cadence ?? null;
265
+ const threadName = buildCronThreadName(baseName, cadence);
266
+ const starterContent = [
267
+ `**Schedule:** \`${record.schedule ?? 'N/A'}\``,
268
+ `**Timezone:** ${record.timezone ?? 'UTC'}`,
269
+ `**Channel:** ${record.channel}`,
270
+ `**Prompt:** ${record.prompt}`,
271
+ `\n[cronId:${cronId}]`,
272
+ ].join('\n');
273
+ const newThread = await forum.threads.create({
274
+ name: threadName,
275
+ message: { content: starterContent },
276
+ });
277
+ // Update the record with the new thread ID and mark synced.
278
+ await statsStore.upsertRecord(cronId, newThread.id, {
279
+ projectionStatus: 'synced',
280
+ projectionSyncedAt: new Date().toISOString(),
281
+ projectionHash: currentHash,
282
+ statusMessageId: undefined,
283
+ promptMessageId: undefined,
284
+ });
285
+ // Re-register in scheduler from canonical definition.
286
+ // Clean up any stale scheduler entry first (e.g., from canonical orphan
287
+ // recovery at boot or threadDelete resilience — the cron may still be
288
+ // registered under the old/deleted thread ID).
289
+ const def = {
290
+ triggerType: record.triggerType ?? 'schedule',
291
+ schedule: record.schedule,
292
+ timezone: record.timezone ?? 'UTC',
293
+ channel: record.channel,
294
+ prompt: record.prompt,
295
+ };
296
+ try {
297
+ const staleJob = scheduler.getJobByCronId(cronId);
298
+ if (staleJob && staleJob.id !== newThread.id) {
299
+ scheduler.unregister(staleJob.id);
300
+ }
301
+ scheduler.register(newThread.id, newThread.id, forum.guildId, threadName, def, cronId);
302
+ if (record.disabled)
303
+ scheduler.disable(newThread.id);
304
+ }
305
+ catch {
306
+ // Registration failed — will retry next sync cycle.
307
+ }
308
+ projectionsRepaired++;
309
+ log?.info({ cronId, newThreadId: newThread.id }, 'cron-sync:phase5 recreated missing projection');
310
+ }
311
+ catch (err) {
312
+ log?.warn({ err, cronId }, 'cron-sync:phase5 failed to recreate missing projection');
313
+ }
314
+ await sleep(throttleMs);
315
+ }
316
+ else {
317
+ // Drifted, pending-resync, or hash mismatch — update status message and mark synced.
318
+ // The status message content was already refreshed in Phase 3 for scheduler-registered
319
+ // jobs; for any remaining records, ensure the projection metadata is up to date.
320
+ try {
321
+ const liveRecord = statsStore.getRecord(cronId);
322
+ if (liveRecord) {
323
+ try {
324
+ await ensureStatusMessage(client, liveRecord.threadId, cronId, liveRecord, statsStore, { log });
325
+ }
326
+ catch {
327
+ // Thread may not exist; status message update is best-effort.
328
+ }
329
+ await statsStore.upsertRecord(cronId, liveRecord.threadId, {
330
+ projectionStatus: 'synced',
331
+ projectionSyncedAt: new Date().toISOString(),
332
+ projectionHash: currentHash,
333
+ });
334
+ }
335
+ projectionsRepaired++;
336
+ }
337
+ catch (err) {
338
+ log?.warn({ err, cronId }, 'cron-sync:phase5 failed to reconcile drifted projection');
339
+ }
340
+ await sleep(throttleMs);
341
+ }
342
+ }
343
+ log?.info({ tagsApplied, namesUpdated, statusMessagesUpdated, promptMessagesCreated, orphansDetected, projectionsRepaired }, 'cron-sync: complete');
344
+ return { tagsApplied, namesUpdated, statusMessagesUpdated, promptMessagesCreated, orphansDetected, projectionsRepaired };
236
345
  }
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { ChannelType } from 'discord.js';
3
3
  import { runCronSync } from './cron-sync.js';
4
+ import { buildCronThreadName } from './discord-sync.js';
4
5
  function makeMockRuntime(output) {
5
6
  return {
6
7
  id: 'other',
@@ -45,6 +46,15 @@ function makeStatsStore(records) {
45
46
  recordRun: vi.fn(async () => { }),
46
47
  removeRecord: vi.fn(async () => true),
47
48
  removeByThreadId: vi.fn(async () => true),
49
+ markProjectionMissing: vi.fn(async () => true),
50
+ markProjectionDrifted: vi.fn(async () => true),
51
+ queueResync: vi.fn(async () => true),
52
+ getCanonicalDefinitions: vi.fn(() => {
53
+ const snapshot = {};
54
+ for (const [id, rec] of Object.entries(store))
55
+ snapshot[id] = { ...rec };
56
+ return snapshot;
57
+ }),
48
58
  };
49
59
  }
50
60
  function makeScheduler(jobs) {
@@ -56,10 +66,39 @@ function makeScheduler(jobs) {
56
66
  return undefined;
57
67
  return { id: j.id, cronId: j.cronId, threadId: j.threadId, guildId: 'g1', name: j.name, def: { schedule: j.schedule, timezone: 'UTC', channel: 'general', prompt: j.prompt }, cron: null, running: false };
58
68
  },
69
+ getJobByCronId: (cronId) => {
70
+ const j = jobs.find((jj) => jj.cronId === cronId);
71
+ if (!j)
72
+ return undefined;
73
+ return { id: j.id, cronId: j.cronId, threadId: j.threadId, guildId: 'g1', name: j.name, def: { schedule: j.schedule, timezone: 'UTC', channel: 'general', prompt: j.prompt }, cron: null, running: false };
74
+ },
75
+ register: vi.fn(),
76
+ unregister: vi.fn(),
77
+ disable: vi.fn(),
59
78
  };
60
79
  }
61
80
  function makeForum(threads) {
62
- const threadMap = new Map(threads.map((t) => [t.id, { ...t, appliedTags: t.appliedTags ?? [], edit: vi.fn(), setName: vi.fn() }]));
81
+ const threadMap = new Map(threads.map((fixture) => {
82
+ const thread = {
83
+ id: fixture.id,
84
+ name: fixture.name,
85
+ parentId: fixture.parentId,
86
+ appliedTags: fixture.appliedTags ?? [],
87
+ client: fixture.client ?? { rest: {} },
88
+ };
89
+ thread.edit = fixture.edit ?? vi.fn(async function (payload) {
90
+ if (payload.appliedTags)
91
+ this.appliedTags = payload.appliedTags;
92
+ if (payload.name)
93
+ this.name = payload.name;
94
+ return this;
95
+ });
96
+ thread.setName = fixture.setName ?? vi.fn(async function (name) {
97
+ this.name = name;
98
+ return this;
99
+ });
100
+ return [thread.id, thread];
101
+ }));
63
102
  return {
64
103
  id: 'forum-1',
65
104
  type: ChannelType.GuildForum,
@@ -177,6 +216,78 @@ describe('runCronSync', () => {
177
216
  });
178
217
  expect(result.namesUpdated).toBe(1);
179
218
  });
219
+ it('phase 2: keeps setName bound to the live thread instance', async () => {
220
+ const expectedName = buildCronThreadName('Daily Check', 'daily');
221
+ const edit = vi.fn(async function (payload) {
222
+ void this.client.rest;
223
+ if (payload.name)
224
+ this.name = payload.name;
225
+ return this;
226
+ });
227
+ const setName = async function (name) {
228
+ return this.edit({ name });
229
+ };
230
+ const forum = makeForum([{ id: 'thread-2', name: 'Old Name', parentId: 'forum-1', edit, setName }]);
231
+ const client = makeClient(forum);
232
+ const log = mockLog();
233
+ const record = makeRecord({ cronId: 'cron-2', threadId: 'thread-2', cadence: 'daily', purposeTags: ['monitoring'], model: 'haiku' });
234
+ const scheduler = makeScheduler([{ id: 'thread-2', threadId: 'thread-2', cronId: 'cron-2', name: 'Daily Check', schedule: '0 7 * * *', prompt: 'Check things' }]);
235
+ const result = await runCronSync({
236
+ client: client,
237
+ forumId: 'forum-1',
238
+ scheduler,
239
+ statsStore: makeStatsStore([record]),
240
+ runtime: makeMockRuntime('monitoring'),
241
+ tagMap: { ...defaultTagMap },
242
+ autoTag: false,
243
+ autoTagModel: 'haiku',
244
+ cwd: '/tmp',
245
+ log,
246
+ throttleMs: 0,
247
+ });
248
+ expect(result.namesUpdated).toBe(1);
249
+ const thread = (await forum.threads.fetchActive()).threads.get('thread-2');
250
+ expect(thread.name).toBe(expectedName);
251
+ expect(edit).toHaveBeenCalledWith({ name: expectedName });
252
+ expect(log.warn).not.toHaveBeenCalledWith(expect.objectContaining({ threadId: 'thread-2' }), 'cron-sync:phase2 name update failed');
253
+ });
254
+ it('phase 1: keeps edit bound to the live thread instance', async () => {
255
+ const edit = vi.fn(async function (payload) {
256
+ void this.client.rest;
257
+ if (payload.appliedTags)
258
+ this.appliedTags = payload.appliedTags;
259
+ return this;
260
+ });
261
+ const forum = makeForum([{ id: 'thread-1', name: 'Test Job', parentId: 'forum-1', edit }]);
262
+ const client = makeClient(forum);
263
+ const log = mockLog();
264
+ const record = makeRecord({
265
+ cronId: 'cron-1',
266
+ threadId: 'thread-1',
267
+ cadence: 'daily',
268
+ purposeTags: ['monitoring'],
269
+ model: 'fast',
270
+ });
271
+ const scheduler = makeScheduler([{ id: 'thread-1', threadId: 'thread-1', cronId: 'cron-1', name: 'Test Job', schedule: '0 7 * * *', prompt: 'Monitor health' }]);
272
+ const result = await runCronSync({
273
+ client: client,
274
+ forumId: 'forum-1',
275
+ scheduler,
276
+ statsStore: makeStatsStore([record]),
277
+ runtime: makeMockRuntime('monitoring'),
278
+ tagMap: { ...defaultTagMap },
279
+ autoTag: true,
280
+ autoTagModel: 'haiku',
281
+ cwd: '/tmp',
282
+ log,
283
+ throttleMs: 0,
284
+ });
285
+ expect(result.tagsApplied).toBe(1);
286
+ const thread = (await forum.threads.fetchActive()).threads.get('thread-1');
287
+ expect(thread.appliedTags).toEqual(['tag-1', 'tag-3']);
288
+ expect(edit).toHaveBeenCalledWith({ appliedTags: ['tag-1', 'tag-3'] });
289
+ expect(log.warn).not.toHaveBeenCalledWith(expect.objectContaining({ threadId: 'thread-1' }), 'cron-sync:phase1 tag apply failed');
290
+ });
180
291
  it('continues with metadata/status phases when fetchActive fails', async () => {
181
292
  const forum = makeForum([]);
182
293
  forum.threads.fetchActive.mockRejectedValueOnce(new Error('Discord API failure'));
@@ -12,7 +12,7 @@ function mockLog() {
12
12
  }
13
13
  function makeCoordinator() {
14
14
  return {
15
- sync: vi.fn(async () => ({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0 })),
15
+ sync: vi.fn(async () => ({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0, projectionsRepaired: 0 })),
16
16
  };
17
17
  }
18
18
  describe('startCronTagMapWatcher', () => {
@@ -9,6 +9,7 @@ import { ensureStatusMessage } from './discord-sync.js';
9
9
  import { globalMetrics } from '../observability/metrics.js';
10
10
  import { mapRuntimeErrorToUserMessage } from '../discord/user-errors.js';
11
11
  import { resolveModel } from '../runtime/model-tiers.js';
12
+ import { resolveGroundedToolCapabilities } from '../runtime/tool-capabilities.js';
12
13
  import { buildCronPromptBody } from './cron-prompt.js';
13
14
  import { buildTieredDiscordActionsPromptSection } from '../discord/actions.js';
14
15
  import { handleJsonRouteOutput } from './json-router.js';
@@ -192,7 +193,12 @@ export async function executeCronJob(job, ctx) {
192
193
  const cronActionFlags = actionRequester.trusted
193
194
  ? ctx.actionFlags
194
195
  : withoutRequesterGatedActionFlags(ctx.actionFlags);
195
- let prompt = buildPromptPreamble(inlinedContext) +
196
+ let prompt = buildPromptPreamble(inlinedContext, {
197
+ runtimeId: ctx.runtime.id,
198
+ runtimeCapabilities: ctx.runtime.capabilities,
199
+ runtimeTools: ctx.tools,
200
+ enableHybridPipeline: ctx.enableHybridPipeline,
201
+ }) +
196
202
  '\n\n' +
197
203
  buildCronPromptBody({
198
204
  jobName: job.name,
@@ -213,7 +219,7 @@ export async function executeCronJob(job, ctx) {
213
219
  const tools = await resolveEffectiveTools({
214
220
  workspaceCwd: ctx.cwd,
215
221
  runtimeTools: ctx.tools,
216
- runtimeCapabilities: ctx.runtime.capabilities,
222
+ runtimeCapabilities: resolveGroundedToolCapabilities(ctx.runtime),
217
223
  runtimeId: ctx.runtime.id,
218
224
  log: ctx.log,
219
225
  });