u-foo 2.5.6 → 2.5.8

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.
@@ -12,8 +12,9 @@ function decomposeBugFixTask(task) {
12
12
  const steps = [];
13
13
  const taskContext = String(task || "");
14
14
 
15
- // Analyze task to determine if it's a bug fix
16
- const isBugFix = /fix|bug|issue|problem|error|broken|doesn't work|not work/i.test(task);
15
+ // Analyze task to determine if it's a bug fix. Word boundaries keep
16
+ // substrings like "fixture"/"prefix"/"debug" from false-matching.
17
+ const isBugFix = /\b(?:fix(?:es|ed|ing)?|bugs?|issues?|problems?|errors?|broken)\b|doesn't work|not work/i.test(taskContext);
17
18
 
18
19
  if (isBugFix) {
19
20
  steps.push({
@@ -67,6 +68,17 @@ function clipStepOutput(value = "", maxChars = 2000) {
67
68
  return `${text.slice(0, maxChars)}\n...[truncated]`;
68
69
  }
69
70
 
71
+ // Progress callbacks are user-supplied; a throwing callback must not abort
72
+ // the task (mirrors nativeRunner's emitToolEvent/emitPhase policy).
73
+ function reportProgress(callback, event = {}) {
74
+ if (typeof callback !== "function") return;
75
+ try {
76
+ callback(event);
77
+ } catch {
78
+ // ignore callback failures
79
+ }
80
+ }
81
+
70
82
  function buildStepPrompt(step, previousResults = []) {
71
83
  const basePrompt = String(step && step.prompt ? step.prompt : "");
72
84
  const prior = Array.isArray(previousResults) ? previousResults : [];
@@ -111,7 +123,6 @@ function shouldEarlyExitStep(step, stepResult = {}) {
111
123
  */
112
124
  async function runDecomposedTask({
113
125
  task,
114
- state,
115
126
  onProgress,
116
127
  onToolEvent,
117
128
  signal,
@@ -143,15 +154,13 @@ async function runDecomposedTask({
143
154
  }
144
155
 
145
156
  // Report progress
146
- if (onProgress) {
147
- onProgress({
148
- type: "step_start",
149
- step: step.id,
150
- name: step.name,
151
- current: steps.indexOf(step) + 1,
152
- total: steps.length,
153
- });
154
- }
157
+ reportProgress(onProgress, {
158
+ type: "step_start",
159
+ step: step.id,
160
+ name: step.name,
161
+ current: steps.indexOf(step) + 1,
162
+ total: steps.length,
163
+ });
155
164
 
156
165
  try {
157
166
  // Run the step with its own timeout
@@ -176,14 +185,12 @@ async function runDecomposedTask({
176
185
  });
177
186
 
178
187
  // Report step completion
179
- if (onProgress) {
180
- onProgress({
181
- type: "step_complete",
182
- step: step.id,
183
- name: step.name,
184
- success: stepResult.ok,
185
- });
186
- }
188
+ reportProgress(onProgress, {
189
+ type: "step_complete",
190
+ step: step.id,
191
+ name: step.name,
192
+ success: stepResult.ok,
193
+ });
187
194
 
188
195
  // Early exit if solution found
189
196
  if (shouldEarlyExitStep(step, stepResult)) {
@@ -202,14 +209,12 @@ async function runDecomposedTask({
202
209
 
203
210
  } catch (err) {
204
211
  // Report step error
205
- if (onProgress) {
206
- onProgress({
207
- type: "step_error",
208
- step: step.id,
209
- name: step.name,
210
- error: err.message,
211
- });
212
- }
212
+ reportProgress(onProgress, {
213
+ type: "step_error",
214
+ step: step.id,
215
+ name: step.name,
216
+ error: err.message,
217
+ });
213
218
 
214
219
  return {
215
220
  ok: false,
@@ -293,7 +298,9 @@ function shellQuote(value = "") {
293
298
  * Create a progress reporter that sends updates via bus
294
299
  */
295
300
  function createBusProgressReporter(shell, publisher) {
296
- let lastReportTime = Date.now();
301
+ // Start at 0 so the first event reports immediately instead of being
302
+ // swallowed by the throttle window.
303
+ let lastReportTime = 0;
297
304
  const MIN_REPORT_INTERVAL = 5000; // Report at most every 5 seconds
298
305
 
299
306
  return (progress) => {
@@ -13,6 +13,28 @@ function isPathInside(root, target) {
13
13
  return normalizedTarget.startsWith(`${normalizedRoot}${path.sep}`);
14
14
  }
15
15
 
16
+ function realpathOrNull(value) {
17
+ try {
18
+ return fs.realpathSync(value);
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ // Realpath the nearest existing ancestor of a path (the path itself when it
25
+ // exists). The workspace root always exists by the time tools run, so the
26
+ // walk terminates there at the latest.
27
+ function realpathNearestExisting(value) {
28
+ let current = value;
29
+ for (;;) {
30
+ const real = realpathOrNull(current);
31
+ if (real) return real;
32
+ const parent = path.dirname(current);
33
+ if (parent === current) return null;
34
+ current = parent;
35
+ }
36
+ }
37
+
16
38
  function resolveWorkspacePath(workspaceRoot = "", targetPath = "", cwd = process.cwd()) {
17
39
  const root = normalizeWorkspaceRoot(workspaceRoot, cwd);
18
40
  const requested = String(targetPath || "").trim();
@@ -23,6 +45,18 @@ function resolveWorkspacePath(workspaceRoot = "", targetPath = "", cwd = process
23
45
  if (!isPathInside(root, resolved)) {
24
46
  throw new Error("path escapes workspace root");
25
47
  }
48
+ // Lexical checks alone let a symlink inside the workspace point outside
49
+ // (e.g. link -> /etc). Re-validate with real paths: realpath the root too
50
+ // so legit roots behind symlinks (macOS /tmp -> /private/tmp) still pass.
51
+ // For missing files, checking the nearest existing ancestor is enough —
52
+ // the not-yet-created tail cannot contain a symlink.
53
+ const realRoot = realpathOrNull(root);
54
+ if (realRoot) {
55
+ const realTarget = realpathNearestExisting(resolved);
56
+ if (!realTarget || !isPathInside(realRoot, realTarget)) {
57
+ throw new Error("path escapes workspace root");
58
+ }
59
+ }
26
60
  return {
27
61
  workspaceRoot: root,
28
62
  requested,
@@ -47,14 +47,22 @@ function runEditTool(input = {}, options = {}) {
47
47
  ? replaceAll(original, find, replace)
48
48
  : replaceOnce(original, find, replace);
49
49
  const changed = applied.count > 0;
50
- if (changed) {
51
- fs.writeFileSync(resolved, applied.next, "utf8");
50
+ if (!changed) {
51
+ return {
52
+ ok: false,
53
+ workspaceRoot,
54
+ path: resolved,
55
+ changed: false,
56
+ replacements: 0,
57
+ error: `find pattern not found in ${resolved}`,
58
+ };
52
59
  }
60
+ fs.writeFileSync(resolved, applied.next, "utf8");
53
61
  return {
54
62
  ok: true,
55
63
  workspaceRoot,
56
64
  path: resolved,
57
- changed,
65
+ changed: true,
58
66
  replacements: applied.count,
59
67
  };
60
68
  } catch (err) {
@@ -12,6 +12,22 @@ const logInject = (message) => {
12
12
  }
13
13
  };
14
14
 
15
+ // osascript 遇到权限弹窗或 System Events 挂起时会永不退出;tmux 客户端
16
+ // 卡死时同样不退出。daemon 的 DeliveryScheduler 会 await 这些 Promise,
17
+ // 没有超时就会永久持有该 subscriber 的推送锁,只能重启 daemon 恢复。
18
+ const SPAWN_TIMEOUT_MS = 10000;
19
+
20
+ function killAfterTimeout(proc, cmd, onTimeout) {
21
+ return setTimeout(() => {
22
+ try {
23
+ proc.kill("SIGKILL");
24
+ } catch {
25
+ // 进程已退出,无需处理
26
+ }
27
+ onTimeout(new Error(`${cmd} timeout after ${SPAWN_TIMEOUT_MS}ms`));
28
+ }, SPAWN_TIMEOUT_MS);
29
+ }
30
+
15
31
  function escapeAppleScriptString(value) {
16
32
  return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
17
33
  }
@@ -28,6 +44,8 @@ function runAppleScript(lines = []) {
28
44
  let stderr = "";
29
45
  let stdout = "";
30
46
 
47
+ const timeout = killAfterTimeout(proc, "osascript", reject);
48
+
31
49
  proc.stdout.on("data", (data) => {
32
50
  stdout += data.toString("utf8");
33
51
  });
@@ -35,13 +53,17 @@ function runAppleScript(lines = []) {
35
53
  stderr += data.toString("utf8");
36
54
  });
37
55
  proc.on("close", (code) => {
56
+ clearTimeout(timeout);
38
57
  if (code === 0) {
39
58
  resolve(stdout.trim());
40
59
  } else {
41
60
  reject(new Error(stderr.trim() || "AppleScript failed"));
42
61
  }
43
62
  });
44
- proc.on("error", reject);
63
+ proc.on("error", (err) => {
64
+ clearTimeout(timeout);
65
+ reject(err);
66
+ });
45
67
  });
46
68
  }
47
69
 
@@ -109,15 +131,18 @@ class Injector {
109
131
  * 检查 tmux pane 是否存在
110
132
  */
111
133
  async checkTmuxPane(paneId) {
112
- return new Promise((resolve) => {
134
+ return new Promise((resolve, reject) => {
113
135
  const proc = spawn("tmux", ["list-panes", "-a", "-F", "#{pane_id}"]);
114
136
  let output = "";
115
137
 
138
+ const timeout = killAfterTimeout(proc, "tmux list-panes", reject);
139
+
116
140
  proc.stdout.on("data", (data) => {
117
141
  output += data.toString();
118
142
  });
119
143
 
120
144
  proc.on("close", (code) => {
145
+ clearTimeout(timeout);
121
146
  if (code !== 0) {
122
147
  resolve(false);
123
148
  return;
@@ -126,7 +151,10 @@ class Injector {
126
151
  resolve(panes.includes(paneId));
127
152
  });
128
153
 
129
- proc.on("error", () => resolve(false));
154
+ proc.on("error", () => {
155
+ clearTimeout(timeout);
156
+ resolve(false);
157
+ });
130
158
  });
131
159
  }
132
160
 
@@ -134,15 +162,18 @@ class Injector {
134
162
  * 根据 tty 查找 tmux pane
135
163
  */
136
164
  async findTmuxPaneByTty(tty) {
137
- return new Promise((resolve) => {
165
+ return new Promise((resolve, reject) => {
138
166
  const proc = spawn("tmux", ["list-panes", "-a", "-F", "#{pane_id} #{pane_tty}"]);
139
167
  let output = "";
140
168
 
169
+ const timeout = killAfterTimeout(proc, "tmux list-panes", reject);
170
+
141
171
  proc.stdout.on("data", (data) => {
142
172
  output += data.toString();
143
173
  });
144
174
 
145
175
  proc.on("close", (code) => {
176
+ clearTimeout(timeout);
146
177
  if (code !== 0) {
147
178
  resolve(null);
148
179
  return;
@@ -158,7 +189,10 @@ class Injector {
158
189
  resolve(null);
159
190
  });
160
191
 
161
- proc.on("error", () => resolve(null));
192
+ proc.on("error", () => {
193
+ clearTimeout(timeout);
194
+ resolve(null);
195
+ });
162
196
  });
163
197
  }
164
198
 
@@ -186,11 +220,14 @@ class Injector {
186
220
  const textProc = spawn("tmux", ["send-keys", "-t", paneId, command]);
187
221
  let stderr = "";
188
222
 
223
+ const textTimeout = killAfterTimeout(textProc, "tmux send-keys", reject);
224
+
189
225
  textProc.stderr.on("data", (data) => {
190
226
  stderr += data.toString();
191
227
  });
192
228
 
193
229
  textProc.on("close", (code) => {
230
+ clearTimeout(textTimeout);
194
231
  if (code !== 0) {
195
232
  reject(new Error(stderr || "tmux send-keys failed"));
196
233
  return;
@@ -198,15 +235,23 @@ class Injector {
198
235
  // Delay before sending Enter — gives the target app time to process input
199
236
  setTimeout(() => {
200
237
  const enterProc = spawn("tmux", ["send-keys", "-t", paneId, "Enter"]);
238
+ const enterTimeout = killAfterTimeout(enterProc, "tmux send-keys Enter", reject);
201
239
  enterProc.on("close", (enterCode) => {
240
+ clearTimeout(enterTimeout);
202
241
  if (enterCode === 0) resolve();
203
242
  else reject(new Error("tmux send-keys Enter failed"));
204
243
  });
205
- enterProc.on("error", reject);
244
+ enterProc.on("error", (err) => {
245
+ clearTimeout(enterTimeout);
246
+ reject(err);
247
+ });
206
248
  }, 150);
207
249
  });
208
250
 
209
- textProc.on("error", reject);
251
+ textProc.on("error", (err) => {
252
+ clearTimeout(textTimeout);
253
+ reject(err);
254
+ });
210
255
  }
211
256
 
212
257
  /**
@@ -1,4 +1,5 @@
1
1
  const fs = require("fs");
2
+ const path = require("path");
2
3
  const { getTimestamp, isAgentPidAlive, isMetaActive, isValidTty, getTtyProcessInfo } = require("./utils");
3
4
  const NicknameManager = require("./nickname");
4
5
  const { spawnSync } = require("child_process");
@@ -113,8 +114,29 @@ class SubscriberManager {
113
114
  appendAgentRegistryDiagnostic(this.agentsFile, event, payload);
114
115
  }
115
116
 
117
+ /**
118
+ * 检查订阅者的 pending.jsonl 中是否仍有未投递消息
119
+ * 读取失败时保守返回 true,避免误删未投递消息
120
+ */
121
+ hasUndeliveredPending(subscriber) {
122
+ try {
123
+ const pendingPath = this.queueManager.getPendingPath
124
+ ? this.queueManager.getPendingPath(subscriber)
125
+ : (this.queueManager.getQueueDir
126
+ ? path.join(this.queueManager.getQueueDir(subscriber), "pending.jsonl")
127
+ : "");
128
+ if (!pendingPath || !fs.existsSync(pendingPath)) return false;
129
+ return fs.readFileSync(pendingPath, "utf8").trim().length > 0;
130
+ } catch {
131
+ return true;
132
+ }
133
+ }
134
+
116
135
  cleanupSubscriberArtifacts(subscriber) {
117
136
  if (!subscriber || !this.queueManager) return;
137
+ // pending.jsonl 里还有未投递消息时绝不能删队列目录和 offset 文件,
138
+ // 保留给订阅者复活或重新激活后继续投递;长期清理由其他机制负责。
139
+ if (this.hasUndeliveredPending(subscriber)) return;
118
140
  try {
119
141
  const queueDir = this.queueManager.getQueueDir
120
142
  ? this.queueManager.getQueueDir(subscriber)
@@ -168,16 +190,21 @@ class SubscriberManager {
168
190
  nickname: meta?.nickname || "",
169
191
  });
170
192
  delete this.busData.agents[id];
193
+ // Migrate undelivered messages to the replacement subscriber before
194
+ // removing the stale queue, so a tty takeover does not drop them.
171
195
  try {
172
- const queueDir = this.queueManager.getQueueDir(id);
173
- if (queueDir) {
174
- fs.rmSync(queueDir, { recursive: true, force: true });
196
+ const dq = this.queueManager.getDeliveryQueue && this.queueManager.getDeliveryQueue(id);
197
+ const undelivered = dq && typeof dq.readPending === "function" ? dq.readPending() : [];
198
+ for (const event of undelivered) {
199
+ await this.queueManager.appendPending(currentSubscriber, event);
200
+ }
201
+ if (undelivered.length > 0 && typeof this.queueManager.clearPending === "function") {
202
+ await this.queueManager.clearPending(id);
175
203
  }
176
- const offsetPath = this.queueManager.getOffsetPath(id);
177
- if (offsetPath) fs.rmSync(offsetPath, { force: true });
178
204
  } catch {
179
- // ignore cleanup errors
205
+ // best-effort migration; the guard below keeps the queue if it failed
180
206
  }
207
+ this.cleanupSubscriberArtifacts(id);
181
208
  }
182
209
  }
183
210
  return inheritedNickname;
@@ -15,6 +15,20 @@ function isDeliverableActivityState(value = "") {
15
15
  return state === "idle" || state === "ready";
16
16
  }
17
17
 
18
+ // Warn once a subscriber has been continuously gate-deferred for this long.
19
+ const DEFAULT_DEFER_WARN_AFTER_MS = 60 * 1000;
20
+ // Minimum interval between repeated defer/lock warnings for the same subscriber.
21
+ const DEFAULT_WARN_INTERVAL_MS = 60 * 1000;
22
+ // After this long stuck in waiting_input/blocked, deliver anyway (better a
23
+ // message in a stuck terminal than a lost one). Env UFOO_DELIVERY_BLOCKED_GRACE_MS overrides.
24
+ const DEFAULT_BLOCKED_GRACE_MS = 15 * 60 * 1000;
25
+ // Warn when an inject lock is held longer than this (usually means a stuck inject).
26
+ const DEFAULT_LOCKED_WARN_AFTER_MS = 60 * 1000;
27
+
28
+ function positiveMs(value, fallback) {
29
+ return Number.isFinite(value) && value > 0 ? value : fallback;
30
+ }
31
+
18
32
  function readAgentsFile(agentsFile) {
19
33
  try {
20
34
  if (!agentsFile || !fs.existsSync(agentsFile)) return { agents: {} };
@@ -45,15 +59,70 @@ class DeliveryScheduler {
45
59
  ? options.emitDelivery
46
60
  : async () => {};
47
61
  this.log = typeof options.log === "function" ? options.log : () => {};
62
+ this.now = typeof options.now === "function" ? options.now : () => Date.now();
63
+ this.deferWarnAfterMs = positiveMs(options.deferWarnAfterMs, DEFAULT_DEFER_WARN_AFTER_MS);
64
+ this.warnIntervalMs = positiveMs(options.warnIntervalMs, DEFAULT_WARN_INTERVAL_MS);
65
+ this.blockedGraceMs = positiveMs(
66
+ options.blockedGraceMs,
67
+ positiveMs(Number(process.env.UFOO_DELIVERY_BLOCKED_GRACE_MS), DEFAULT_BLOCKED_GRACE_MS),
68
+ );
69
+ this.lockedWarnAfterMs = positiveMs(options.lockedWarnAfterMs, DEFAULT_LOCKED_WARN_AFTER_MS);
48
70
  this.intervalMs = Number.isFinite(options.intervalMs) && options.intervalMs > 0
49
71
  ? options.intervalMs
50
72
  : 1000;
51
73
  this.adapterRouter = options.adapterRouter || createTerminalAdapterRouter();
52
- this.locks = new Set();
74
+ this.locks = new Map();
75
+ this.deferrals = new Map();
76
+ this.blockedStateSeen = new Map();
77
+ this.graceWarned = new Map();
53
78
  this.timer = null;
54
79
  this.running = false;
55
80
  }
56
81
 
82
+ pendingCount(subscriber) {
83
+ try {
84
+ const queue = this.queueFactory(subscriber);
85
+ if (queue && typeof queue.readPending === "function") return queue.readPending().length;
86
+ } catch {
87
+ // logging must never break delivery
88
+ }
89
+ return "unknown";
90
+ }
91
+
92
+ noteDeferral(subscriber, reason) {
93
+ const now = this.now();
94
+ const prev = this.deferrals.get(subscriber);
95
+ if (!prev || prev.reason !== reason) {
96
+ // Log once per reason change instead of on every 1s tick.
97
+ this.deferrals.set(subscriber, { reason, sinceMs: now, lastWarnAtMs: 0 });
98
+ this.log(`delivery deferred subscriber=${subscriber} reason=${reason} pending=${this.pendingCount(subscriber)}`);
99
+ return;
100
+ }
101
+ if (now - prev.sinceMs >= this.deferWarnAfterMs && now - prev.lastWarnAtMs >= this.warnIntervalMs) {
102
+ prev.lastWarnAtMs = now;
103
+ this.log(`WARN delivery still deferred subscriber=${subscriber} reason=${reason} pending=${this.pendingCount(subscriber)} deferred_ms=${now - prev.sinceMs}`);
104
+ }
105
+ }
106
+
107
+ clearDeferral(subscriber) {
108
+ this.deferrals.delete(subscriber);
109
+ }
110
+
111
+ noteGraceOverride(subscriber, activityState) {
112
+ if (this.graceWarned.get(subscriber) === activityState) return;
113
+ this.graceWarned.set(subscriber, activityState);
114
+ this.log(`WARN delivery grace override subscriber=${subscriber} activity_state=${activityState} pending=${this.pendingCount(subscriber)} grace_ms=${this.blockedGraceMs} - agent stuck, delivering anyway`);
115
+ }
116
+
117
+ noteLocked(subscriber, lock) {
118
+ const now = this.now();
119
+ const lockedMs = now - lock.sinceMs;
120
+ if (lockedMs >= this.lockedWarnAfterMs && now - lock.lastWarnAtMs >= this.warnIntervalMs) {
121
+ lock.lastWarnAtMs = now;
122
+ this.log(`WARN delivery lock held subscriber=${subscriber} locked_ms=${lockedMs} pending=${this.pendingCount(subscriber)} - previous inject may be stuck, daemon restart may be required`);
123
+ }
124
+ }
125
+
57
126
  getAgentMeta(subscriber) {
58
127
  const data = this.readAgents() || { agents: {} };
59
128
  const agents = data.agents && typeof data.agents === "object" ? data.agents : {};
@@ -75,23 +144,52 @@ class DeliveryScheduler {
75
144
  }
76
145
  const activityState = asState(meta.activity_state);
77
146
  if (!isDeliverableActivityState(activityState)) {
147
+ if (activityState === "waiting_input" || activityState === "blocked") {
148
+ const sinceMs = this.resolveBlockedStateSinceMs(subscriber, meta, activityState);
149
+ if (this.now() - sinceMs >= this.blockedGraceMs) {
150
+ return { ok: true, reason: "deliverable", graceOverride: activityState };
151
+ }
152
+ } else {
153
+ this.blockedStateSeen.delete(subscriber);
154
+ }
78
155
  return { ok: false, reason: activityState || "unknown_activity_state" };
79
156
  }
157
+ this.blockedStateSeen.delete(subscriber);
80
158
  return { ok: true, reason: "deliverable" };
81
159
  }
82
160
 
161
+ // Prefer meta.activity_since (written on state change); fall back to the
162
+ // first time this scheduler observed the state when the field is missing.
163
+ resolveBlockedStateSinceMs(subscriber, meta, activityState) {
164
+ const parsed = Date.parse(meta && meta.activity_since != null ? String(meta.activity_since) : "");
165
+ if (Number.isFinite(parsed)) return parsed;
166
+ const seen = this.blockedStateSeen.get(subscriber);
167
+ if (seen && seen.state === activityState) return seen.sinceMs;
168
+ const now = this.now();
169
+ this.blockedStateSeen.set(subscriber, { state: activityState, sinceMs: now });
170
+ return now;
171
+ }
172
+
83
173
  async deliverSubscriber(subscriber) {
84
174
  if (!subscriber) return { ok: false, delivered: 0, reason: "missing_subscriber" };
85
175
  if (this.locks.has(subscriber)) {
176
+ this.noteLocked(subscriber, this.locks.get(subscriber));
86
177
  return { ok: true, delivered: 0, deferred: true, reason: "locked" };
87
178
  }
88
179
 
89
- this.locks.add(subscriber);
180
+ this.locks.set(subscriber, { sinceMs: this.now(), lastWarnAtMs: 0 });
90
181
  try {
91
182
  const gate = this.shouldDeliver(subscriber);
92
183
  if (!gate.ok) {
184
+ this.noteDeferral(subscriber, gate.reason);
93
185
  return { ok: true, delivered: 0, deferred: true, reason: gate.reason };
94
186
  }
187
+ if (gate.graceOverride) {
188
+ this.noteGraceOverride(subscriber, gate.graceOverride);
189
+ } else {
190
+ this.graceWarned.delete(subscriber);
191
+ }
192
+ this.clearDeferral(subscriber);
95
193
 
96
194
  const queue = this.queueFactory(subscriber);
97
195
  const claim = queue.claimNext();
@@ -114,9 +212,11 @@ class DeliveryScheduler {
114
212
  if (delivery.gate === "idle") {
115
213
  const secondGate = this.shouldDeliver(subscriber);
116
214
  if (!secondGate.ok) {
215
+ this.noteDeferral(subscriber, secondGate.reason);
117
216
  queue.restoreClaim(claim);
118
217
  return { ok: true, delivered: 0, deferred: true, reason: secondGate.reason };
119
218
  }
219
+ if (secondGate.graceOverride) this.noteGraceOverride(subscriber, secondGate.graceOverride);
120
220
  }
121
221
 
122
222
  const { agents } = this.getAgentMeta(subscriber);
@@ -1340,7 +1340,14 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1340
1340
  log(`report bus event failed request=${meta.requestId || ""} error=${err.message || String(err)}`);
1341
1341
  }
1342
1342
  });
1343
- const deliveryScheduler = new DeliveryScheduler(projectRoot, { log });
1343
+ const deliveryScheduler = new DeliveryScheduler(projectRoot, {
1344
+ log,
1345
+ emitDelivery: async ({ subscriber, status, error } = {}) => {
1346
+ if (status === "error") {
1347
+ log(`delivery failed subscriber=${subscriber || "unknown"} error=${error || "unknown"}`);
1348
+ }
1349
+ },
1350
+ });
1344
1351
  deliveryScheduler.start();
1345
1352
 
1346
1353
  handleIpcRequest = async (req, socket) => {
@@ -282,11 +282,28 @@ function buildShellEnvPrefix(extraEnv = {}) {
282
282
  .join(" ");
283
283
  }
284
284
 
285
+ // osascript can hang forever on permission prompts or a stuck System Events;
286
+ // launch and close both await runAppleScript, so without a timeout the daemon
287
+ // would stay blocked until restart.
288
+ const APPLESCRIPT_TIMEOUT_MS = 10000;
289
+
290
+ function killAfterTimeout(proc, cmd, onTimeout) {
291
+ return setTimeout(() => {
292
+ try {
293
+ proc.kill("SIGKILL");
294
+ } catch {
295
+ // process already exited, nothing to kill
296
+ }
297
+ onTimeout(new Error(`${cmd} timeout after ${APPLESCRIPT_TIMEOUT_MS}ms`));
298
+ }, APPLESCRIPT_TIMEOUT_MS);
299
+ }
300
+
285
301
  function runAppleScript(lines) {
286
302
  return new Promise((resolve, reject) => {
287
303
  const proc = spawn("osascript", lines.flatMap((l) => ["-e", l]));
288
304
  let stderr = "";
289
305
  let stdout = "";
306
+ const timeout = killAfterTimeout(proc, "osascript", reject);
290
307
  proc.stderr.on("data", (d) => {
291
308
  stderr += d.toString("utf8");
292
309
  });
@@ -294,9 +311,14 @@ function runAppleScript(lines) {
294
311
  stdout += d.toString("utf8");
295
312
  });
296
313
  proc.on("close", (code) => {
314
+ clearTimeout(timeout);
297
315
  if (code === 0) resolve(stdout.trim());
298
316
  else reject(new Error(stderr || "osascript failed"));
299
317
  });
318
+ proc.on("error", (err) => {
319
+ clearTimeout(timeout);
320
+ reject(err);
321
+ });
300
322
  });
301
323
  }
302
324
 
@@ -1317,5 +1339,6 @@ module.exports = {
1317
1339
  toTerminalBinary,
1318
1340
  toTmuxBinary,
1319
1341
  buildResumeArgs,
1342
+ runAppleScript,
1320
1343
  },
1321
1344
  };