talon-agent 5.18.2 → 5.19.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 (190) hide show
  1. package/README.md +2 -1
  2. package/package.json +1 -1
  3. package/prompts/system/agent-brief.md +20 -3
  4. package/src/app.ts +13 -0
  5. package/src/backend/claude-sdk/handler.ts +4 -4
  6. package/src/backend/claude-sdk/mcp-ready.ts +16 -2
  7. package/src/backend/claude-sdk/stream.ts +2 -2
  8. package/src/backend/codex/auth.ts +1 -1
  9. package/src/backend/codex/handler/message.ts +11 -11
  10. package/src/backend/codex/init.ts +4 -9
  11. package/src/backend/codex/mcp-config.ts +1 -2
  12. package/src/backend/codex/oauth-incompat.ts +8 -4
  13. package/src/backend/codex/one-shot.ts +1 -1
  14. package/src/backend/openai-agents/builtins.ts +55 -27
  15. package/src/backend/openai-agents/factory.ts +3 -3
  16. package/src/backend/openai-agents/handler/message.ts +3 -5
  17. package/src/backend/openai-agents/mcp-pool.ts +10 -27
  18. package/src/backend/remote-server/chat-turn.ts +6 -6
  19. package/src/backend/remote-server/events.ts +1 -5
  20. package/src/backend/remote-server/index.ts +0 -1
  21. package/src/backend/remote-server/messages.ts +3 -7
  22. package/src/backend/remote-server/one-shot.ts +1 -3
  23. package/src/backend/remote-server/session-helpers.ts +1 -4
  24. package/src/backend/remote-server/sse-stream.ts +8 -9
  25. package/src/backend/runtime/metrics.ts +7 -13
  26. package/src/backend/runtime/sleep.ts +1 -2
  27. package/src/backend/runtime/turn/handle-retry.ts +48 -2
  28. package/src/backend/runtime/turn/handler-to-events.ts +3 -4
  29. package/src/bootstrap.ts +9 -1
  30. package/src/cli/doctor.ts +3 -0
  31. package/src/cli/index.ts +8 -10
  32. package/src/cli/logs.ts +148 -9
  33. package/src/cli/setup.ts +9 -11
  34. package/src/cli/status.ts +29 -0
  35. package/src/core/agent-runtime/README.md +5 -19
  36. package/src/core/agent-runtime/events.ts +3 -39
  37. package/src/core/agent-runtime/model-ref.ts +0 -8
  38. package/src/core/agents/registry.ts +49 -2
  39. package/src/core/auth/expiry-monitor.ts +9 -1
  40. package/src/core/auth/login-flow.ts +9 -1
  41. package/src/core/auth/status.ts +31 -3
  42. package/src/core/background/cron/scheduler.ts +25 -5
  43. package/src/core/background/dream/index.ts +29 -10
  44. package/src/core/background/failure-backoff.ts +30 -0
  45. package/src/core/background/heartbeat/agent.ts +2 -27
  46. package/src/core/background/heartbeat/index.ts +0 -2
  47. package/src/core/background/heartbeat/scheduler.ts +21 -13
  48. package/src/core/background/heartbeat/state.ts +10 -2
  49. package/src/core/background/isolated-agent.ts +6 -2
  50. package/src/core/background/pulse/pulse.ts +9 -0
  51. package/src/core/background/triggers/exit.ts +54 -0
  52. package/src/core/background/triggers/index.ts +1 -3
  53. package/src/core/background/triggers/resume.ts +2 -4
  54. package/src/core/backup/archive/tar.ts +14 -4
  55. package/src/core/backup/restore.ts +21 -13
  56. package/src/core/backup/scheduler.ts +28 -9
  57. package/src/core/backup/snapshot.ts +43 -10
  58. package/src/core/backup/store.ts +7 -19
  59. package/src/core/backup/targets.ts +69 -17
  60. package/src/core/config/index.ts +14 -0
  61. package/src/core/daemon/crash-marker.ts +141 -0
  62. package/src/core/daemon/crash.ts +9 -2
  63. package/src/core/daemon/handoff.ts +15 -0
  64. package/src/core/daemon/health-alerts.ts +297 -0
  65. package/src/core/daemon/log-reader.ts +289 -0
  66. package/src/core/doctor/index.ts +18 -2
  67. package/src/core/doctor/logs.ts +124 -0
  68. package/src/core/doctor/types.ts +1 -1
  69. package/src/core/engine/backend-controller/index.ts +1 -13
  70. package/src/core/engine/backend-router/router.ts +1 -1
  71. package/src/core/engine/dispatcher.ts +55 -2
  72. package/src/core/engine/fault-text.ts +40 -0
  73. package/src/core/engine/gateway-actions/agents/index.ts +3 -2
  74. package/src/core/engine/gateway-actions/agents/report.ts +62 -0
  75. package/src/core/engine/gateway-actions/history.ts +2 -4
  76. package/src/core/engine/gateway-actions/native/exec.ts +13 -16
  77. package/src/core/engine/gateway.ts +60 -1
  78. package/src/core/engine/turn-health.ts +222 -0
  79. package/src/core/errors.ts +2 -2
  80. package/src/core/frontend-runtime/admin-notify.ts +1 -1
  81. package/src/core/frontend-runtime/alerts.ts +130 -0
  82. package/src/core/mcp-hub/children.ts +78 -29
  83. package/src/core/mcp-hub/index.ts +21 -18
  84. package/src/core/mcp-hub/proxy-server.ts +8 -4
  85. package/src/core/mcp-hub/talon-server.ts +5 -12
  86. package/src/core/mesh/credentials/store.ts +16 -1
  87. package/src/core/mesh/devices/service.ts +26 -15
  88. package/src/core/mesh/devices/teleport.ts +14 -2
  89. package/src/core/mesh/links/node-binaries.ts +13 -6
  90. package/src/core/mesh/persist.ts +22 -10
  91. package/src/core/mesh/transfers/device-files.ts +5 -23
  92. package/src/core/mesh/transfers/transfers.ts +16 -3
  93. package/src/core/models/active-model.ts +2 -55
  94. package/src/core/plugin/actions.ts +19 -20
  95. package/src/core/plugin/builtins.ts +80 -90
  96. package/src/core/plugin/index.ts +1 -4
  97. package/src/core/plugin/loader.ts +25 -33
  98. package/src/core/plugin/mcp.ts +3 -5
  99. package/src/core/plugin/registry.ts +19 -35
  100. package/src/core/plugin/types.ts +2 -5
  101. package/src/core/prompt/assemble.ts +15 -3
  102. package/src/core/scripts/lua.ts +6 -2
  103. package/src/core/tasks/table.ts +8 -2
  104. package/src/core/tools/bridge.ts +2 -4
  105. package/src/core/tools/chat/cross-send.ts +1 -1
  106. package/src/core/tools/chat/messaging.ts +1 -1
  107. package/src/core/tools/index.ts +2 -2
  108. package/src/core/tools/mcp-env.ts +2 -59
  109. package/src/core/tools/ops/agents.ts +22 -1
  110. package/src/core/tools/schemas.ts +4 -9
  111. package/src/core/vfs/fusefs.ts +0 -5
  112. package/src/core/vfs/index.ts +9 -2
  113. package/src/core/vfs/mounts/diagnostics.ts +109 -0
  114. package/src/core/vfs/mounts/proc.ts +17 -1
  115. package/src/core/vfs/workspace.ts +7 -3
  116. package/src/core/weaver/shuttle.ts +8 -1
  117. package/src/core/weaver/turn-log.ts +320 -0
  118. package/src/core/weaver/weaver.ts +48 -5
  119. package/src/frontend/discord/actions/index.ts +8 -1
  120. package/src/frontend/discord/diagnostics.ts +82 -6
  121. package/src/frontend/discord/handlers/index.ts +0 -2
  122. package/src/frontend/discord/middleware.ts +7 -13
  123. package/src/frontend/discord/runtime.ts +1 -3
  124. package/src/frontend/health/delivery.ts +115 -0
  125. package/src/frontend/health/outage.ts +116 -0
  126. package/src/frontend/native/bridge/routes/chats.ts +3 -5
  127. package/src/frontend/native/bridge/server.ts +106 -21
  128. package/src/frontend/native/index.ts +1 -1
  129. package/src/frontend/native/media/media.ts +5 -1
  130. package/src/frontend/native/runtime.ts +12 -7
  131. package/src/frontend/native/surface/handlers.ts +1 -1
  132. package/src/frontend/native/surface/memory.ts +1 -1
  133. package/src/frontend/native/surface/models.ts +3 -3
  134. package/src/frontend/native/surface/settings.ts +20 -8
  135. package/src/frontend/native/turn/context.ts +6 -8
  136. package/src/frontend/native/turn/turn-meta.ts +2 -5
  137. package/src/frontend/native/turn/turn.ts +8 -10
  138. package/src/frontend/presentation/format.ts +2 -4
  139. package/src/frontend/presentation/session-status.ts +2 -6
  140. package/src/frontend/teams/actions.ts +8 -1
  141. package/src/frontend/teams/graph.ts +0 -1
  142. package/src/frontend/teams/index.ts +1 -4
  143. package/src/frontend/teams/poll.ts +40 -2
  144. package/src/frontend/teams/runtime.ts +14 -5
  145. package/src/frontend/telegram/actions/index.ts +4 -1
  146. package/src/frontend/telegram/actions/send.ts +8 -0
  147. package/src/frontend/telegram/handlers/context.ts +13 -2
  148. package/src/frontend/telegram/handlers/delivery.ts +12 -9
  149. package/src/frontend/telegram/handlers/index.ts +0 -2
  150. package/src/frontend/telegram/index.ts +35 -9
  151. package/src/frontend/telegram/polling/poll-health.ts +110 -0
  152. package/src/frontend/telegram/userbot.ts +100 -36
  153. package/src/frontend/terminal/builtins/session.ts +2 -2
  154. package/src/frontend/terminal/index.ts +1 -3
  155. package/src/frontend/terminal/renderer.ts +2 -18
  156. package/src/frontend/whatsapp/actions/index.ts +12 -1
  157. package/src/frontend/whatsapp/actions/messaging.ts +7 -2
  158. package/src/frontend/whatsapp/connection/connection.ts +29 -9
  159. package/src/frontend/whatsapp/connection/health.ts +89 -0
  160. package/src/frontend/whatsapp/connection/identity.ts +4 -4
  161. package/src/frontend/whatsapp/runtime.ts +11 -5
  162. package/src/native/blake3.ts +28 -2
  163. package/src/native/fusefs.ts +23 -5
  164. package/src/native/registry.ts +1 -1
  165. package/src/native/warden.ts +33 -5
  166. package/src/plugins/github/index.ts +0 -1
  167. package/src/plugins/mempalace/index.ts +9 -3
  168. package/src/plugins/playwright/index.ts +2 -4
  169. package/src/plugins/playwright/provision.ts +12 -4
  170. package/src/storage/chat-settings.ts +6 -1
  171. package/src/storage/cron.ts +29 -4
  172. package/src/storage/daily-log.ts +43 -47
  173. package/src/storage/db.ts +61 -33
  174. package/src/storage/history.ts +6 -1
  175. package/src/storage/journal.ts +9 -2
  176. package/src/storage/kv.ts +19 -6
  177. package/src/storage/media-index.ts +28 -6
  178. package/src/storage/repositories/chat-settings-repo.ts +8 -2
  179. package/src/storage/repositories/sessions-repo.ts +10 -3
  180. package/src/storage/scripts.ts +24 -13
  181. package/src/storage/sessions.ts +11 -2
  182. package/src/storage/skills.ts +21 -2
  183. package/src/storage/stickers.ts +17 -3
  184. package/src/storage/triggers.ts +8 -3
  185. package/src/storage/turn-meta.ts +25 -7
  186. package/src/util/log.ts +189 -6
  187. package/src/util/logging/turn-scope.ts +85 -0
  188. package/src/util/time.ts +3 -3
  189. package/src/util/watchdog.ts +30 -0
  190. package/src/core/engine/backend-controller/legacy.ts +0 -111
@@ -17,11 +17,12 @@
17
17
 
18
18
  import { existsSync, unlinkSync } from "node:fs";
19
19
  import { blake3HexFile } from "../native/blake3.js";
20
- import { log, logError } from "../util/log.js";
20
+ import { log, logError, logWarn } from "../util/log.js";
21
21
  import { recordError } from "../util/watchdog.js";
22
22
  import { files } from "../util/paths.js";
23
23
  import { importLegacyJson } from "./legacy-import.js";
24
24
  import { setMessageFilePath } from "./history.js";
25
+ import { dbErrorFields } from "./db.js";
25
26
  import * as repo from "./repositories/media-index-repo.js";
26
27
 
27
28
  export type { MediaEntry } from "./repositories/media-index-repo.js";
@@ -75,7 +76,11 @@ export function addMedia(entry: Omit<MediaEntry, "id">): void {
75
76
  try {
76
77
  repo.upsert(entry);
77
78
  } catch (err) {
78
- logError("media", "Media index save failed", err);
79
+ logError(
80
+ "media",
81
+ `Media index save failed chat=${entry.chatId} msg=${entry.msgId} path=${entry.filePath}${dbErrorFields(err)}`,
82
+ err,
83
+ );
79
84
  recordError(
80
85
  `Media index write failed: ${err instanceof Error ? err.message : err}`,
81
86
  );
@@ -116,8 +121,13 @@ async function hashAndDedupe(entry: Omit<MediaEntry, "id">): Promise<void> {
116
121
  "media",
117
122
  `Deduped ${entry.filePath} -> ${canonical.filePath} (blake3 ${hash.slice(0, 12)}…)`,
118
123
  );
119
- } catch {
120
- /* dedupe is best-effort; the entry already points at the canonical copy */
124
+ } catch (err) {
125
+ // Dedupe is best-effort — the entry already points at the canonical
126
+ // copy — but the duplicate now sits on disk unreferenced.
127
+ logWarn(
128
+ "media",
129
+ `Dedupe unlink failed path=${entry.filePath}: ${err instanceof Error ? err.message : String(err)}`,
130
+ );
121
131
  }
122
132
  }
123
133
  }
@@ -161,17 +171,29 @@ function purgeExpired(): void {
161
171
  const removed = repo.deleteOlderThan(cutoff);
162
172
  // Rows first, files second: content dedupe means several entries can
163
173
  // share one file, so only unlink paths no surviving row references.
174
+ let unlinkFailures = 0;
175
+ let firstFailure = "";
164
176
  for (const path of new Set(expired.map((e) => e.filePath))) {
165
177
  if (repo.countByFilePath(path) > 0) continue;
166
178
  try {
167
179
  if (existsSync(path)) unlinkSync(path);
168
- } catch {
169
- /* skip */
180
+ } catch (err) {
181
+ // Skip it, but count: files left behind are disk that the purge
182
+ // thinks it reclaimed.
183
+ if (unlinkFailures++ === 0) {
184
+ firstFailure = `${path}: ${err instanceof Error ? err.message : String(err)}`;
185
+ }
170
186
  }
171
187
  }
172
188
  if (removed > 0) {
173
189
  log("media", `Purged ${removed} expired media entries`);
174
190
  }
191
+ if (unlinkFailures > 0) {
192
+ logWarn(
193
+ "media",
194
+ `Media purge left ${unlinkFailures} expired file(s) on disk; first=${firstFailure}`,
195
+ );
196
+ }
175
197
  } catch (err) {
176
198
  logError("media", "Media index purge failed", err);
177
199
  }
@@ -11,6 +11,7 @@
11
11
  * queried independently in SQL.
12
12
  */
13
13
 
14
+ import { logWarn } from "../../util/log.js";
14
15
  import { getDatabase, inTransaction } from "../db.js";
15
16
  import { chatSettingsSql } from "../sql/statements.generated.js";
16
17
  import type { ReasoningEffortLevel } from "../../types/effort.js";
@@ -102,8 +103,13 @@ export function all(): Array<{ chatId: string; settings: ChatSettings }> {
102
103
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
103
104
  result.push({ chatId: row.chat_id, settings: parsed });
104
105
  }
105
- } catch {
106
- /* skip unparseable row */
106
+ } catch (err) {
107
+ // Skip the unparseable row; the chat silently falls back to
108
+ // defaults otherwise.
109
+ logWarn(
110
+ "settings",
111
+ `Skipped corrupt chat settings chat=${row.chat_id}: ${err instanceof Error ? err.message : String(err)}`,
112
+ );
107
113
  }
108
114
  }
109
115
  return result;
@@ -12,6 +12,7 @@
12
12
  * column default can hold.
13
13
  */
14
14
 
15
+ import { logWarn } from "../../util/log.js";
15
16
  import { getDatabase, inTransaction } from "../db.js";
16
17
  import { sessionsSql } from "../sql/statements.generated.js";
17
18
  import {
@@ -46,13 +47,19 @@ type Row = {
46
47
  metrics: string | null;
47
48
  };
48
49
 
49
- function parseMetrics(raw: string | null): SessionMetrics {
50
+ function parseMetrics(raw: string | null, chatId: string): SessionMetrics {
50
51
  if (!raw) return emptyMetrics();
51
52
  try {
52
53
  // Normalise at the load boundary: backfills partial blobs and restores
53
54
  // the Infinity minMs sentinel that JSON round-trips as null.
54
55
  return normaliseMetrics(JSON.parse(raw));
55
- } catch {
56
+ } catch (err) {
57
+ // Degrade to empty — and say so, since the next upsert overwrites
58
+ // the corrupt blob and the chat's lifetime counters restart at zero.
59
+ logWarn(
60
+ "sessions",
61
+ `Corrupt session metrics chat=${chatId} bytes=${raw.length} — resetting: ${err instanceof Error ? err.message : String(err)}`,
62
+ );
56
63
  return emptyMetrics();
57
64
  }
58
65
  }
@@ -78,7 +85,7 @@ function rowToSession(row: Row): SessionState {
78
85
  // NULL = no timed turn yet — the domain sentinel is Infinity.
79
86
  fastestResponseMs: row.fastest_response_ms ?? Infinity,
80
87
  },
81
- metrics: parseMetrics(row.metrics),
88
+ metrics: parseMetrics(row.metrics, row.chat_id),
82
89
  lastBotMessageId: row.last_bot_message_id ?? undefined,
83
90
  sessionName: row.session_name ?? undefined,
84
91
  lastModel: row.last_model ?? undefined,
@@ -17,8 +17,10 @@
17
17
  */
18
18
 
19
19
  import { randomUUID } from "node:crypto";
20
- import { mkdirSync, rmSync, writeFileSync } from "node:fs";
20
+ import { mkdirSync, rmSync } from "node:fs";
21
21
  import { dirname, resolve } from "node:path";
22
+ import writeFileAtomic from "write-file-atomic";
23
+ import { logWarn } from "../util/log.js";
22
24
  import { dirs } from "../util/paths.js";
23
25
  import * as repo from "./repositories/scripts-repo.js";
24
26
 
@@ -93,8 +95,9 @@ function writeScriptFile(
93
95
  ): string {
94
96
  const path = scriptFilePath(name, lang);
95
97
  mkdirSync(dirname(path), { recursive: true });
96
- // 0o700: only the user running Talon should be able to read/exec scripts
97
- writeFileSync(path, body, { encoding: "utf-8", mode: 0o700 });
98
+ // 0o700: only the user running Talon should be able to read/exec scripts.
99
+ // Atomic: a failed replace (ENOSPC) must keep the previous body.
100
+ writeFileAtomic.sync(path, body, { encoding: "utf-8", mode: 0o700 });
98
101
  return path;
99
102
  }
100
103
 
@@ -117,11 +120,7 @@ export function saveScript(input: {
117
120
  }): Script {
118
121
  const existing = repo.getByName(input.name);
119
122
  if (existing && existing.language !== input.language) {
120
- try {
121
- rmSync(existing.scriptPath, { force: true });
122
- } catch {
123
- /* best effort */
124
- }
123
+ removeScriptFile(existing.scriptPath, input.name);
125
124
  }
126
125
  const scriptPath = writeScriptFile(input.name, input.language, input.script);
127
126
  const now = Date.now();
@@ -153,15 +152,27 @@ export function recordScriptUse(name: string): void {
153
152
  }
154
153
 
155
154
  /** Delete a script and best-effort clean up its on-disk file. */
155
+ /**
156
+ * Best-effort unlink. `force` already absorbs a missing file, so any
157
+ * throw here is a real fault (permissions, a directory in the way) that
158
+ * leaves an orphan script on disk.
159
+ */
160
+ function removeScriptFile(path: string, name: string): void {
161
+ try {
162
+ rmSync(path, { force: true });
163
+ } catch (err) {
164
+ logWarn(
165
+ "scripts",
166
+ `Could not remove script file name=${name} path=${path}: ${err instanceof Error ? err.message : String(err)}`,
167
+ );
168
+ }
169
+ }
170
+
156
171
  export function deleteScript(name: string): boolean {
157
172
  const script = repo.getByName(name);
158
173
  if (!script) return false;
159
174
  repo.removeByName(name);
160
- try {
161
- rmSync(script.scriptPath, { force: true });
162
- } catch {
163
- /* best effort */
164
- }
175
+ removeScriptFile(script.scriptPath, name);
165
176
  return true;
166
177
  }
167
178
 
@@ -22,6 +22,7 @@ import { log, logError } from "../util/log.js";
22
22
  import { recordError } from "../util/watchdog.js";
23
23
  import { files } from "../util/paths.js";
24
24
  import { importLegacyJson } from "./legacy-import.js";
25
+ import { dbErrorFields } from "./db.js";
25
26
  import * as repo from "./repositories/sessions-repo.js";
26
27
 
27
28
  export type {
@@ -204,7 +205,11 @@ function persist(chatId: string, session: SessionState): void {
204
205
  try {
205
206
  repo.upsert(chatId, session);
206
207
  } catch (err) {
207
- logError("sessions", "Failed to persist sessions", err);
208
+ logError(
209
+ "sessions",
210
+ `Failed to persist sessions chat=${chatId}${dbErrorFields(err)}`,
211
+ err,
212
+ );
208
213
  recordError(
209
214
  `Session save failed: ${err instanceof Error ? err.message : err}`,
210
215
  );
@@ -518,7 +523,11 @@ function removeSessionRow(chatId: string): void {
518
523
  try {
519
524
  repo.remove(chatId);
520
525
  } catch (err) {
521
- logError("sessions", "Failed to delete session row", err);
526
+ logError(
527
+ "sessions",
528
+ `Failed to delete session row chat=${chatId}${dbErrorFields(err)}`,
529
+ err,
530
+ );
522
531
  recordError(
523
532
  `Session delete failed: ${err instanceof Error ? err.message : err}`,
524
533
  );
@@ -26,7 +26,9 @@ import {
26
26
  writeFileSync,
27
27
  } from "node:fs";
28
28
  import { basename, resolve } from "node:path";
29
+ import writeFileAtomic from "write-file-atomic";
29
30
  import { parseDocument, stringify } from "yaml";
31
+ import { logWarn } from "../util/log.js";
30
32
  import { dirs } from "../util/paths.js";
31
33
 
32
34
  export type Skill = {
@@ -123,6 +125,13 @@ function serializeSkill(input: {
123
125
  return ["---", yaml, "---", "", input.body.trimEnd(), ""].join("\n");
124
126
  }
125
127
 
128
+ /**
129
+ * Frontmatter errors already reported, keyed by path + message: skills
130
+ * are re-read for every prompt, so a broken SKILL.md warns once, not
131
+ * once per turn.
132
+ */
133
+ const reportedFrontmatterErrors = new Set<string>();
134
+
126
135
  function parseSkill(
127
136
  path: string,
128
137
  raw: string,
@@ -161,7 +170,15 @@ function parseSkill(
161
170
  if (value && typeof value === "object") {
162
171
  parsed = value as Record<string, unknown>;
163
172
  }
164
- } catch {
173
+ } catch (err) {
174
+ // The skill still loads (name falls back to its directory, no
175
+ // description) — which is exactly why the reason must be visible.
176
+ const msg = err instanceof Error ? err.message : String(err);
177
+ const key = `${path}\0${msg}`;
178
+ if (!reportedFrontmatterErrors.has(key)) {
179
+ reportedFrontmatterErrors.add(key);
180
+ logWarn("skills", `Unparseable frontmatter in ${path}: ${msg}`);
181
+ }
165
182
  parsed = {};
166
183
  }
167
184
 
@@ -216,7 +233,9 @@ export function saveSkill(input: {
216
233
  mkdirSync(dir, { recursive: true });
217
234
  const path = skillFilePath(input.name);
218
235
  const content = serializeSkill(input);
219
- writeFileSync(path, content, { encoding: "utf-8", mode: 0o600 });
236
+ // Atomic: a failed replace (ENOSPC) must keep the previous SKILL.md,
237
+ // not leave a truncated one.
238
+ writeFileAtomic.sync(path, content, { encoding: "utf-8", mode: 0o600 });
220
239
  return readSkill(input.name)!;
221
240
  }
222
241
 
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { readFileSync, readdirSync } from "node:fs";
16
16
  import { resolve } from "node:path";
17
+ import { logWarn } from "../util/log.js";
17
18
  import { dirs } from "../util/paths.js";
18
19
 
19
20
  // ── Types ───────────────────────────────────────────────────────────────────
@@ -50,7 +51,15 @@ export function listSavedPacks(): SavedPack[] {
50
51
  let entries: string[];
51
52
  try {
52
53
  entries = readdirSync(dir).filter((f) => f.endsWith(".json"));
53
- } catch {
54
+ } catch (err) {
55
+ // No stickers dir yet is the normal empty library; anything else
56
+ // (EACCES, ENOTDIR) hides every saved pack.
57
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
58
+ logWarn(
59
+ "stickers",
60
+ `Could not list sticker packs dir=${dir}: ${err instanceof Error ? err.message : String(err)}`,
61
+ );
62
+ }
54
63
  return [];
55
64
  }
56
65
  const packs: SavedPack[] = [];
@@ -82,8 +91,13 @@ export function listSavedPacks(): SavedPack[] {
82
91
  stickers,
83
92
  savedAt: typeof raw.savedAt === "string" ? raw.savedAt : "",
84
93
  });
85
- } catch {
86
- /* skip malformed pack file */
94
+ } catch (err) {
95
+ // Skip the malformed pack file, but name it — it silently drops
96
+ // out of the library otherwise.
97
+ logWarn(
98
+ "stickers",
99
+ `Skipped unreadable sticker pack file=${file}: ${err instanceof Error ? err.message : String(err)}`,
100
+ );
87
101
  }
88
102
  }
89
103
  return packs.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
@@ -19,7 +19,7 @@ import {
19
19
  } from "node:fs";
20
20
  import { randomUUID } from "node:crypto";
21
21
  import { dirname, resolve } from "node:path";
22
- import { log, logError } from "../util/log.js";
22
+ import { log, logError, logWarn } from "../util/log.js";
23
23
  import { dirs, files } from "../util/paths.js";
24
24
  import { importLegacyJson } from "./legacy-import.js";
25
25
  import { inTransaction } from "./db.js";
@@ -286,8 +286,13 @@ export function deleteTrigger(id: string): boolean {
286
286
  for (const path of [t.scriptPath, t.logPath]) {
287
287
  try {
288
288
  rmSync(path, { force: true });
289
- } catch {
290
- /* best effort */
289
+ } catch (err) {
290
+ // Best effort; `force` already absorbs a missing file, so this is
291
+ // a real fault that leaves an orphan on disk.
292
+ logWarn(
293
+ "triggers",
294
+ `Could not remove trigger file id=${id} path=${path}: ${err instanceof Error ? err.message : String(err)}`,
295
+ );
291
296
  }
292
297
  }
293
298
  return true;
@@ -14,11 +14,11 @@
14
14
  * actually fetch.
15
15
  */
16
16
 
17
- import { inTransaction } from "./db.js";
17
+ import { dbErrorFields, inTransaction } from "./db.js";
18
18
  import * as repo from "./repositories/turn-meta-repo.js";
19
19
  import { importLegacyJson } from "./legacy-import.js";
20
20
  import { files } from "../util/paths.js";
21
- import { logError } from "../util/log.js";
21
+ import { logError, logWarn } from "../util/log.js";
22
22
 
23
23
  /** Per-chat cap — matches the /history page ceiling. */
24
24
  const MAX_PER_CHAT = 500;
@@ -83,7 +83,11 @@ export function recordTurnMeta(
83
83
  repo.prune(chatId, MAX_PER_CHAT);
84
84
  });
85
85
  } catch (err) {
86
- logError("db", "Failed to persist turn meta", err);
86
+ logError(
87
+ "db",
88
+ `Failed to persist turn meta chat=${chatId} msg=${msgId}${dbErrorFields(err)}`,
89
+ err,
90
+ );
87
91
  }
88
92
  }
89
93
 
@@ -96,14 +100,24 @@ export function getTurnMeta<T>(chatId: string, msgId: string): T | null {
96
100
  } catch (err) {
97
101
  // Reads are hydration-only — a storage fault must degrade to
98
102
  // "no meta", not break the caller (matching recordTurnMeta).
99
- logError("db", "Failed to read turn meta", err);
103
+ logError(
104
+ "db",
105
+ `Failed to read turn meta chat=${chatId} msg=${msgId}${dbErrorFields(err)}`,
106
+ err,
107
+ );
100
108
  return null;
101
109
  }
102
110
  if (raw === undefined) return null;
103
111
  try {
104
112
  return JSON.parse(raw) as T;
105
- } catch {
106
- return null; // corrupt row — treat as absent rather than throw
113
+ } catch (err) {
114
+ // Corrupt row — treat as absent rather than throw, but say so: the
115
+ // caller sees "no meta" and can't tell it from a message never tagged.
116
+ logWarn(
117
+ "db",
118
+ `Corrupt turn meta chat=${chatId} msg=${msgId} bytes=${raw.length}: ${err instanceof Error ? err.message : String(err)}`,
119
+ );
120
+ return null;
107
121
  }
108
122
  }
109
123
 
@@ -113,6 +127,10 @@ export function clearTurnMeta(chatId: string): void {
113
127
  try {
114
128
  repo.removeChat(chatId);
115
129
  } catch (err) {
116
- logError("db", "Failed to clear turn meta", err);
130
+ logError(
131
+ "db",
132
+ `Failed to clear turn meta chat=${chatId}${dbErrorFields(err)}`,
133
+ err,
134
+ );
117
135
  }
118
136
  }