jeo-code 0.11.2 → 0.11.4

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.
@@ -276,15 +276,12 @@ export interface AgentLoopEvents {
276
276
  /** Step-budget change (gjc-style retry flow): the limit was extended because the
277
277
  * turn is making progress. `limit` is the new max; `reason` is display-ready. */
278
278
  onBudget?(limit: number, reason: string): void;
279
- /** Consulted when a lone `done` arrives. Return a corrective message to bounce
280
- * the done ONCE (e.g. "todo list still shows unfinished items — update it
281
- * first"); return null to let the turn finish. The engine guarantees at most
282
- * one bounce per turn, so a stubborn model can never loop here. `evidence` is
283
- * the SAME mutation/verification signal the engine's own done-gate already
284
- * computed (see `classifyDoneGate` above) — a caller-owned gate (e.g. the goal
285
- * verifier) can use it to deterministically downgrade an LLM-judged MET verdict
286
- * when the turn mutated files without a fresh passing verification, instead of
287
- * trusting the transcript self-report alone. */
279
+ /** Consulted on every completion attempt, including salvaged prose. Return a
280
+ * corrective message to defer completion; null approves the caller-owned gate.
281
+ * Corrections are bounded by GUARD_LIMITS.MAX_DONE_CORRECTIONS, after which a
282
+ * rejected attempt stops incomplete. `evidence` is the SAME mutation/verification
283
+ * signal the engine's own done-gate uses, so callers can reject unsupported
284
+ * transcript-only success claims. */
288
285
  onBeforeDone?(
289
286
  reason: string,
290
287
  evidence: { sawMutation: boolean; sawVerification: boolean; verificationStale: boolean }
@@ -583,10 +580,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
583
580
  // calls (bad edits, failing commands) would otherwise burn the whole step budget.
584
581
  const MAX_FAILURES = GUARD_LIMITS.MAX_FAILURES;
585
582
  let consecutiveFailures = 0;
586
- // done-verification guard (plan/gjc-inheritance.md B4, gjc ultragoal-guard 경량 계승):
587
- // a turn that MUTATED files but shows no verification signal gets ONE pushback on
588
- // `done` — run the relevant test/build, or call done again (the escape hatch for
589
- // doc/config changes where verification is genuinely not applicable).
583
+ // Completion requires fresh passing verification after a successful mutation.
590
584
  let sawMutation = false;
591
585
  let sawVerification = false;
592
586
  // Monotonic ordering of mutation vs. verification events so the done gate can
@@ -595,16 +589,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
595
589
  let mutVerSeq = 0;
596
590
  let lastMutationSeq = 0;
597
591
  let lastVerificationSeq = 0;
598
- let donePushbackUsed = false;
599
- // Abuse guard for the done-pushback escape hatch: the "second done always
600
- // passes" latch below is meant for turns where verification is genuinely
601
- // not applicable (docs/config-only changes) — not for a model that just
602
- // resends `done` immediately with zero intervening action. Reset to false
603
- // when the first pushback fires; any executed tool step (even read-only)
604
- // sets it back to true, so the escape hatch requires at least one attempt.
605
- let sawActionSincePushback = true;
606
- // Caller-owned done gate (onBeforeDone) — also strictly once per turn.
607
- let beforeDoneNudgeUsed = false;
592
+ let doneCorrections = 0;
608
593
  // F1 (round 4): the run-command of the most recent post-turn hook FAILURE whose
609
594
  // diagnostics the model saw but has not yet resolved (a later clean hook run
610
595
  // clears it). The done guard treats this as "verification missing" — the hook
@@ -981,6 +966,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
981
966
  if (sawUsage) ev.onUsage?.({ ...acc }, lastCallUsage ? { ...lastCallUsage } : undefined);
982
967
 
983
968
  let invocation: any;
969
+ let salvagedAnswer = false;
984
970
  try {
985
971
  const extracted = extractJsonObjectWithSpan<any>(responseText, { preferKeys: ["tool", "tools"] });
986
972
  invocation = extracted.value;
@@ -991,30 +977,27 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
991
977
  // would reinforce the corruption instead of self-correcting.
992
978
  responseText = extracted.matched;
993
979
  } catch (err) {
994
- ev.onAssistant?.(responseText, null);
995
- // Prose salvage: a reply with no JSON object at all is a chat-style final
996
- // answer, not a malformed tool call. Bouncing it back only made the model
997
- // apologize for the format — and that apology surfaced as the visible reply.
998
- // Same salvage after repeated bounces: the text we have IS the best answer.
980
+ // Preserve chat-style final answers, but subject them to the same completion
981
+ // gates as explicit done calls rather than treating prose as proof of success.
999
982
  const trimmed = responseText.trim();
1000
983
  parseFailures++;
1001
984
  if (trimmed && (!trimmed.includes("{") || parseFailures > MAX_PARSE_BOUNCES)) {
985
+ salvagedAnswer = true;
986
+ invocation = { tool: "done", arguments: { reason: stripLeakedReasoningTags(trimmed) || trimmed } };
987
+ } else {
988
+ ev.onAssistant?.(responseText, null);
1002
989
  pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
1003
- // Strip leaked <think>/<parameter>/Harmony scaffolding some API-entered
1004
- // models emit inline so the salvaged answer doesn't surface raw tags.
1005
- return finish({ done: true, steps: step, doneReason: stripLeakedReasoningTags(trimmed) || trimmed });
990
+ history.push({
991
+ role: "user",
992
+ content:
993
+ `Your last reply was not a valid tool call (${(err as Error).message}). ` +
994
+ `Do NOT apologize or explain the formatting mistake. If that reply was your final answer, ` +
995
+ `resend it as {"tool":"done","arguments":{"reason":"<that answer, verbatim>"}}; ` +
996
+ `otherwise reply with exactly one JSON tool call: {"tool":"<name>","arguments":{...}}.`,
997
+ });
998
+ step++;
999
+ continue;
1006
1000
  }
1007
- pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
1008
- history.push({
1009
- role: "user",
1010
- content:
1011
- `Your last reply was not a valid tool call (${(err as Error).message}). ` +
1012
- `Do NOT apologize or explain the formatting mistake. If that reply was your final answer, ` +
1013
- `resend it as {"tool":"done","arguments":{"reason":"<that answer, verbatim>"}}; ` +
1014
- `otherwise reply with exactly one JSON tool call: {"tool":"<name>","arguments":{...}}.`,
1015
- });
1016
- step++;
1017
- continue;
1018
1001
  }
1019
1002
  // A successfully parsed reply ends any bounce streak: MAX_PARSE_BOUNCES is a
1020
1003
  // CONSECUTIVE-failure salvage, not a cumulative one — without this reset a long
@@ -1066,64 +1049,27 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1066
1049
  toolCalls = toolCalls.slice(0, 6);
1067
1050
  }
1068
1051
 
1069
- ev.onAssistant?.(responseText, toolCalls[0]);
1052
+ ev.onAssistant?.(responseText, salvagedAnswer ? null : toolCalls[0]);
1070
1053
 
1071
1054
  if (toolCalls.length === 1 && toolCalls[0].tool === "done") {
1072
- // done-verification gate — jeo's descendant of gjc's ultragoal-guard completion
1073
- // state machine (plan/gjc-inheritance.md B4). The classifier owns the JUDGMENT
1074
- // (which named state, which message); the loop owns the once-pushback latch.
1075
- const doneGate = classifyDoneGate({
1055
+ const evidence = {
1076
1056
  sawMutation,
1077
1057
  sawVerification,
1078
1058
  verificationStale: sawMutation && lastMutationSeq > lastVerificationSeq,
1079
- pendingHookFailure,
1080
- });
1081
- if (doneGate.block) {
1082
- if (!donePushbackUsed) {
1083
- donePushbackUsed = true;
1084
- sawActionSincePushback = false; // require a real attempt before the 2nd done is honored
1085
- pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
1086
- history.push({ role: "user", content: doneGate.message });
1087
- step++;
1088
- continue;
1089
- }
1090
- if (!sawActionSincePushback) {
1091
- // Abuse guard: the model re-sent `done` immediately with zero intervening
1092
- // tool calls — not the intended "verification genuinely not applicable"
1093
- // escape hatch. Bounce once more instead of silently accepting it.
1094
- pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
1095
- history.push({
1096
- role: "user",
1097
- content:
1098
- `${doneGate.message}\n\nYou called 'done' again without taking any further action. ` +
1099
- `Either run the relevant verification (test/build/lint) or take another concrete step, ` +
1100
- `then call done — do not resend 'done' unchanged.`,
1101
- });
1102
- step++;
1103
- continue;
1104
- }
1105
- // donePushbackUsed && sawActionSincePushback: the model made at least one
1106
- // real attempt since the pushback — honor the escape hatch.
1107
- }
1108
- // Caller-owned done gate (e.g. stale-todo reconciliation): ONE bounded
1109
- // bounce, then any later done passes — field case: a 28-step turn ended
1110
- // [DONE] with the Todos checklist still showing 1 in-progress + 4 pending
1111
- // because nothing ever forced a status update.
1112
- if (!beforeDoneNudgeUsed && ev.onBeforeDone) {
1113
- const nudge = await ev.onBeforeDone((toolCalls[0].arguments?.reason as string) ?? "", {
1114
- sawMutation,
1115
- sawVerification,
1116
- verificationStale: sawMutation && lastMutationSeq > lastVerificationSeq,
1117
- });
1118
-
1119
- if (nudge) {
1120
- beforeDoneNudgeUsed = true;
1121
- pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
1122
- history.push({ role: "user", content: nudge });
1123
- ev.onNotice?.("done deferred once — final plan reconciliation requested");
1124
- step++;
1125
- continue;
1059
+ };
1060
+ const doneGate = classifyDoneGate({ ...evidence, pendingHookFailure });
1061
+ const nudge = await ev.onBeforeDone?.((toolCalls[0].arguments?.reason as string) ?? "", evidence);
1062
+ const rejection = [doneGate.block ? doneGate.message : "", nudge].filter(Boolean).join("\n\n");
1063
+ if (rejection) {
1064
+ if (doneCorrections >= GUARD_LIMITS.MAX_DONE_CORRECTIONS) {
1065
+ return finish({ done: false, steps: step, doneReason: `Stopped: completion rejected after ${doneCorrections} corrections. ${rejection}` });
1126
1066
  }
1067
+ doneCorrections++;
1068
+ pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
1069
+ history.push({ role: "user", content: rejection });
1070
+ ev.onNotice?.(`done deferred — completion correction ${doneCorrections}/${GUARD_LIMITS.MAX_DONE_CORRECTIONS}`);
1071
+ step++;
1072
+ continue;
1127
1073
  }
1128
1074
  // Steering that arrived DURING this final step (after the top-of-loop drain,
1129
1075
  // while the model was generating its `done`): reopen the turn and handle it now
@@ -1148,6 +1094,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1148
1094
  continue;
1149
1095
  }
1150
1096
  }
1097
+ if (salvagedAnswer) pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
1151
1098
  return finish({ done: true, steps: step, doneReason: stripLeakedReasoningTags((toolCalls[0].arguments?.reason as string) ?? "") });
1152
1099
  }
1153
1100
 
@@ -1445,20 +1392,18 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1445
1392
  if (results[i].executed) budget.record(callSigs[i], results[i].success, callTargets[i]);
1446
1393
 
1447
1394
  }
1448
- // done-verification guard bookkeeping: write/edit successes mark the turn as
1449
- // mutating; a successful bash whose command/output looks like a test/build run
1450
- // counts as verification. A monotonic seq records ORDER so the done gate can
1451
- // reject a verification that predates the last mutation (stale evidence).
1395
+ // Only the latest recognized verification result is evidence; a later failure
1396
+ // invalidates earlier success. Sequence numbers also reject tests before edits.
1452
1397
  for (let i = 0; i < toolCalls.length; i++) {
1453
- if (!results[i].executed || !results[i].success) continue;
1398
+ if (!results[i].executed) continue;
1454
1399
  const t = toolCalls[i].tool;
1455
- if (t === "write" || t === "edit") {
1400
+ if ((t === "write" || t === "edit") && results[i].success) {
1456
1401
  sawMutation = true;
1457
1402
  lastMutationSeq = ++mutVerSeq;
1458
1403
  } else if (t === "bash") {
1459
1404
  const cmd = String(toolCalls[i].arguments?.command ?? "");
1460
1405
  if (isVerificationSignal(cmd, results[i].output)) {
1461
- sawVerification = true;
1406
+ sawVerification = results[i].success;
1462
1407
  lastVerificationSeq = ++mutVerSeq;
1463
1408
  }
1464
1409
  }
@@ -1494,7 +1439,6 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1494
1439
  // Executed tool step = progress: the stall budget clocks time WITHOUT this.
1495
1440
  if (results.some(r => r.executed)) {
1496
1441
  lastProgressAt = Date.now();
1497
- sawActionSincePushback = true; // any real step after a pushback re-enables the done escape hatch
1498
1442
  }
1499
1443
  step++;
1500
1444
  }
@@ -41,6 +41,8 @@ export const GUARD_LIMITS = Object.freeze({
41
41
  MAX_REPEAT: 4,
42
42
  /** Consecutive different-but-failing steps before the turn stops. */
43
43
  MAX_FAILURES: 5,
44
+ /** Rejected completion attempts that may request a correction before stopping incomplete. */
45
+ MAX_DONE_CORRECTIONS: 3,
44
46
  /** Context-mutating refusal-ladder rungs (resend → reset → guidance strip) before
45
47
  * recovery switches to unbounded backoff resends (gjc parity: never terminal). */
46
48
  MAX_REFUSAL_RETRIES: 3,
@@ -114,7 +116,7 @@ export function repeatHint(tool: string, prev?: { success: boolean; output: stri
114
116
  export interface DoneGateInput {
115
117
  /** A write/edit succeeded this turn. */
116
118
  sawMutation: boolean;
117
- /** A test/build/typecheck/lint command succeeded this turn. */
119
+ /** The most recent recognized test/build/typecheck/lint command succeeded. */
118
120
  sawVerification: boolean;
119
121
  /**
120
122
  * A mutation landed AFTER the most recent successful verification — so the
@@ -129,7 +131,7 @@ export interface DoneGateInput {
129
131
  /** Verdict from {@link classifyDoneGate}: whether to bounce `done`, and the message. */
130
132
  export interface DoneGateVerdict {
131
133
  state: Extract<GuardState, "done_ok" | "done_unverified" | "done_stale_verification" | "done_hook_failing">;
132
- /** When true, `done` should be bounced ONCE with `message` (the caller owns the once-gate). */
134
+ /** When true, completion must remain blocked until the condition is resolved. */
133
135
  block: boolean;
134
136
  /** Corrective message to push back on `done`; empty when `state === "done_ok"`. */
135
137
  message: string;
@@ -139,10 +141,9 @@ export interface DoneGateVerdict {
139
141
  * Classify whether a `done` should be accepted or bounced — the direct descendant of
140
142
  * gjc's `ultragoal-guard` completion gate (plan/gjc-inheritance.md B4).
141
143
  *
142
- * A turn that MUTATED files is blocked ONCE when its verification is missing
143
- * (no test/build signal), STALE (a passing run that predates the last edit), or
144
- * a post-turn hook is still failing. The caller owns the single-pushback latch; a
145
- * second `done` always passes (the escape hatch for unverifiable docs/config changes).
144
+ * A turn that MUTATED files remains blocked while verification is missing/failed,
145
+ * STALE (a passing run predates the last edit), or a post-turn hook is still failing.
146
+ * The caller bounds corrective retries without converting rejection into success.
146
147
  */
147
148
  export function classifyDoneGate(input: DoneGateInput): DoneGateVerdict {
148
149
  const hookFailing = input.pendingHookFailure !== null;
@@ -155,8 +156,7 @@ export function classifyDoneGate(input: DoneGateInput): DoneGateVerdict {
155
156
  block: true,
156
157
  message:
157
158
  `Your latest mutation left the post-turn hook "${input.pendingHookFailure}" FAILING (non-zero exit) — its diagnostics were shown in the tool result above. ` +
158
- "Fix the reported problems (the hook re-runs on your next mutation), then call done. " +
159
- "If the hook failure is a false positive, call done again and say why in the reason.",
159
+ "Fix the reported problems (the hook re-runs on your next mutation), then call done.",
160
160
  };
161
161
  }
162
162
  if (stale) {
@@ -165,16 +165,14 @@ export function classifyDoneGate(input: DoneGateInput): DoneGateVerdict {
165
165
  block: true,
166
166
  message:
167
167
  "You verified earlier, but then modified files again — your last passing test/build no longer reflects the current tree. " +
168
- "Re-run the narrowest verification command against the latest changes, then call done. " +
169
- "If the later edits are verification-irrelevant (docs/config-only), call done again and say why in the reason.",
168
+ "Re-run the narrowest verification command against the latest changes, then call done.",
170
169
  };
171
170
  }
172
171
  return {
173
172
  state: "done_unverified",
174
173
  block: true,
175
174
  message:
176
- "You modified files this turn but ran NO verification (no test/build/typecheck command succeeded). " +
177
- "Run the narrowest command that proves your change works, then call done. " +
178
- "If verification is genuinely not applicable (docs/config-only change), call done again and say why in the reason.",
175
+ "You modified files this turn but have no passing verification (no test/build/typecheck command succeeded, or the latest recognized verification failed). " +
176
+ "Run the narrowest command that proves your change works, then call done.",
179
177
  };
180
178
  }
@@ -15,6 +15,8 @@ import { readGlobalConfig } from "../state";
15
15
  export interface DaemonLockInfo {
16
16
  pid: number;
17
17
  startedAt: number;
18
+ purpose?: "daemon" | "pairing";
19
+ ready?: boolean;
18
20
  }
19
21
 
20
22
  export function isPidAlive(pid: number): boolean {
@@ -152,32 +154,87 @@ export async function readDaemonLock(): Promise<DaemonLockInfo | undefined> {
152
154
  const raw = await fs.readFile(notifyDaemonLockPath(), "utf-8");
153
155
  const parsed = JSON.parse(raw) as Partial<DaemonLockInfo>;
154
156
  if (typeof parsed.pid !== "number" || typeof parsed.startedAt !== "number") return undefined;
155
- return { pid: parsed.pid, startedAt: parsed.startedAt };
157
+ if (!Number.isSafeInteger(parsed.pid) || parsed.pid <= 0 || !Number.isFinite(parsed.startedAt)) return undefined;
158
+ return { pid: parsed.pid, startedAt: parsed.startedAt, purpose: parsed.purpose, ready: parsed.ready };
156
159
  } catch {
157
160
  return undefined;
158
161
  }
159
162
  }
160
163
 
161
- /** Called by the daemon process itself on startup. Returns `undefined` when a
162
- * live owner already holds the lock (caller must exit without polling
163
- * Telegram); otherwise writes the lock and returns a `release()` closure. */
164
- export async function acquireDaemonLock(): Promise<{ release: () => Promise<void> } | undefined> {
164
+ async function interruptedRecoveryMessage(): Promise<string | undefined> {
165
+ const guard = `${notifyDaemonLockPath()}.reclaim`;
166
+ const stat = await fs.stat(guard).catch(() => undefined);
167
+ if (stat && Date.now() - stat.mtimeMs >= 30_000) {
168
+ return `Notification lock recovery is blocked by ${guard}. Stop all daemon/pairing processes and verify their owners are gone before manually removing this recovery guard; it is not automatically deleted because that can race a live lock owner.`;
169
+ }
170
+ return undefined;
171
+ }
172
+
173
+ /** Exclusive process lock shared with Telegram pairing, which also owns getUpdates. */
174
+ export async function acquireDaemonLock(purpose: "daemon" | "pairing" = "daemon"): Promise<{ release: () => Promise<void>; markReady: () => Promise<void> } | undefined> {
165
175
  await fs.mkdir(notifyDir(), { recursive: true, mode: 0o700 });
166
- const existing = await readDaemonLock();
167
- if (existing && existing.pid !== process.pid && (await isLockOwnerAlive(existing))) return undefined;
168
- const info: DaemonLockInfo = { pid: process.pid, startedAt: Date.now() };
169
- await fs.writeFile(notifyDaemonLockPath(), JSON.stringify(info), { mode: 0o600 });
176
+ const lockPath = notifyDaemonLockPath();
177
+ let contents = JSON.stringify({ pid: process.pid, startedAt: await processStartTimeMs(process.pid) ?? Date.now(), purpose, ready: false, owner: crypto.randomUUID() });
178
+ const create = async () => {
179
+ // Publish a fully written record atomically; a killed writer cannot leave partial JSON.
180
+ const tmp = `${lockPath}.${crypto.randomUUID()}.tmp`;
181
+ try {
182
+ await fs.writeFile(tmp, contents, { mode: 0o600, flag: "wx" });
183
+ await fs.link(tmp, lockPath);
184
+ return true;
185
+ } catch (error) {
186
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
187
+ throw error;
188
+ } finally { await fs.unlink(tmp).catch(() => {}); }
189
+ };
190
+ if (!(await create())) {
191
+ // Serialize stale-owner reclamation so a second contender cannot unlink the winner.
192
+ let reclaim: fs.FileHandle;
193
+ try { reclaim = await fs.open(`${lockPath}.reclaim`, "wx", 0o600); }
194
+ catch (error) {
195
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
196
+ const blocked = await interruptedRecoveryMessage();
197
+ if (blocked) throw new Error(blocked);
198
+ return undefined;
199
+ }
200
+ throw error;
201
+ }
202
+ try {
203
+ const existing = await readDaemonLock();
204
+ if (existing && await isLockOwnerAlive(existing)) return undefined;
205
+ // Recover legacy malformed locks only after a grace period for old writers.
206
+ if (!existing && Date.now() - (await fs.stat(lockPath)).mtimeMs < 30_000) return undefined;
207
+ await fs.unlink(lockPath);
208
+ if (!(await create())) return undefined;
209
+ } finally {
210
+ await reclaim.close();
211
+ await fs.unlink(`${lockPath}.reclaim`).catch(() => {});
212
+ }
213
+ }
170
214
  return {
215
+ markReady: async () => {
216
+ if (await fs.readFile(lockPath, "utf-8").catch(() => "") !== contents) throw new Error("Notification daemon lost lock ownership during startup");
217
+ const next = JSON.stringify({ ...JSON.parse(contents), ready: true });
218
+ const tmp = `${lockPath}.${crypto.randomUUID()}.tmp`;
219
+ try {
220
+ await fs.writeFile(tmp, next, { mode: 0o600, flag: "wx" });
221
+ await fs.rename(tmp, lockPath);
222
+ contents = next;
223
+ } finally { await fs.unlink(tmp).catch(() => {}); }
224
+ },
171
225
  release: async () => {
172
- await fs.unlink(notifyDaemonLockPath()).catch(() => {});
226
+ // An old release closure must not remove a newer owner's lock.
227
+ if (await fs.readFile(lockPath, "utf-8").catch(() => "") === contents) await fs.unlink(lockPath).catch(() => {});
173
228
  },
174
229
  };
175
230
  }
176
231
 
177
232
  export interface DaemonStatus {
178
- /** `notifications.enabled` + a stored bot token + chat id are all present. */
233
+ /** Master toggle and at least one complete, authorized transport. */
179
234
  configured: boolean;
180
235
  running: boolean;
236
+ pairing?: boolean;
237
+ ready?: boolean;
181
238
  /** A lock file exists but its pid is dead — a previous daemon crashed without cleanup. */
182
239
  stale: boolean;
183
240
  pid?: number;
@@ -190,7 +247,12 @@ export interface DaemonStatus {
190
247
 
191
248
  export async function isNotifyConfigured(): Promise<boolean> {
192
249
  const config = await readGlobalConfig();
193
- return Boolean(config.notifications?.enabled && config.notifications.telegram?.botToken && config.notifications.telegram?.chatId);
250
+ const n = config.notifications;
251
+ return Boolean(n?.enabled && (
252
+ (n.telegram?.botToken && n.telegram.chatId) ||
253
+ (n.discord?.botToken && n.discord.channelId && n.discord.allowedUserIds?.length) ||
254
+ (n.slack?.botToken && n.slack.appToken && n.slack.channelId && n.slack.allowedUserIds?.length)
255
+ ));
194
256
  }
195
257
 
196
258
  export async function daemonStatus(): Promise<DaemonStatus> {
@@ -199,7 +261,7 @@ export async function daemonStatus(): Promise<DaemonStatus> {
199
261
  const { alive, verified } = await inspectLockOwner(lock);
200
262
  // Surface the degraded case instead of hiding it: on a host with no `/proc` and no
201
263
  // usable `ps`, "running" is an existence-only guess that cannot rule out PID reuse.
202
- return { configured, running: alive, stale: !alive, pid: lock.pid, startedAt: lock.startedAt, ownerVerified: verified };
264
+ return { configured, running: alive && lock.purpose !== "pairing", pairing: alive && lock.purpose === "pairing", ready: alive && lock.ready === true, stale: !alive, pid: lock.pid, startedAt: lock.startedAt, ownerVerified: verified };
203
265
  }
204
266
 
205
267
  /** Self-invocation argv for the daemon child (mirrors `memory.ts`'s
@@ -240,10 +302,11 @@ async function waitFor(predicate: () => Promise<boolean>, timeoutMs: number, ste
240
302
  return false;
241
303
  }
242
304
 
243
- export async function startDaemon(spawnImpl: SpawnLike = defaultSpawn): Promise<{ ok: boolean; pid?: number; message: string }> {
305
+ export async function startDaemon(spawnImpl: SpawnLike = defaultSpawn, waitImpl: (predicate: () => Promise<boolean>, timeoutMs: number) => Promise<boolean> = waitFor): Promise<{ ok: boolean; pid?: number; message: string }> {
244
306
  const existing = await readDaemonLock();
245
307
  if (existing && (await isLockOwnerAlive(existing))) {
246
- return { ok: true, pid: existing.pid, message: `daemon already running (pid ${existing.pid})` };
308
+ if (existing.purpose === "pairing") return { ok: false, message: "Telegram pairing currently owns the notification lock; finish setup first." };
309
+ return { ok: existing.ready === true, pid: existing.pid, message: `daemon ${existing.ready !== true ? "is still initializing" : "already running"} (pid ${existing.pid}); connectivity not checked` };
247
310
  }
248
311
  // Check BEFORE spawning: an unconfigured daemon exits almost immediately (see
249
312
  // `runNotifyDaemonForeground`), which races the readiness poll below and used to
@@ -252,24 +315,45 @@ export async function startDaemon(spawnImpl: SpawnLike = defaultSpawn): Promise<
252
315
  return { ok: false, message: "notifications not configured — run 'jeo notify setup' first." };
253
316
  }
254
317
 
318
+ const blocked = await interruptedRecoveryMessage();
319
+ if (blocked) return { ok: false, message: blocked };
320
+
255
321
  const cmd = daemonInvocation(process.argv[1], process.execPath, process.cwd());
256
322
  spawnImpl(cmd, process.cwd()).unref();
257
- const ready = await waitFor(async () => {
323
+ const ready = await waitImpl(async () => {
258
324
  const lock = await readDaemonLock();
259
- return Boolean(lock && isPidAlive(lock.pid));
260
- }, 2_000);
261
- if (!ready) return { ok: false, message: `daemon did not report ready within 2s — check ${notifyDaemonLogPath()}` };
325
+ return Boolean(lock && lock.purpose === "daemon" && lock.ready === true && isPidAlive(lock.pid));
326
+ }, 15_000);
327
+ if (!ready) return { ok: false, message: `daemon did not initialize within 15s — check ${notifyDaemonLogPath()}` };
262
328
  const lock = await readDaemonLock();
263
- return { ok: true, pid: lock?.pid, message: `daemon started (pid ${lock?.pid})` };
329
+ return { ok: true, pid: lock?.pid, message: `daemon initialized (pid ${lock?.pid}); use notify health to check platform access` };
330
+ }
331
+
332
+ async function removeStoppedLock(expected: DaemonLockInfo): Promise<void> {
333
+ const lockPath = notifyDaemonLockPath();
334
+ let guard: fs.FileHandle;
335
+ try { guard = await fs.open(`${lockPath}.reclaim`, "wx", 0o600); }
336
+ catch (error) {
337
+ if (["EEXIST", "ENOENT"].includes((error as NodeJS.ErrnoException).code ?? "")) return;
338
+ throw error;
339
+ }
340
+ try {
341
+ const current = await readDaemonLock();
342
+ if (current?.pid === expected.pid && current.startedAt === expected.startedAt && !(await isLockOwnerAlive(current))) await fs.unlink(lockPath).catch(() => {});
343
+ } finally {
344
+ await guard.close();
345
+ await fs.unlink(`${lockPath}.reclaim`).catch(() => {});
346
+ }
264
347
  }
265
348
 
266
349
  export async function stopDaemon(): Promise<{ ok: boolean; message: string }> {
267
350
  const lock = await readDaemonLock();
268
351
  const owner = lock ? await inspectLockOwner(lock) : undefined;
269
352
  if (!lock || !owner?.alive) {
270
- await fs.unlink(notifyDaemonLockPath()).catch(() => {});
353
+ if (lock) await removeStoppedLock(lock);
271
354
  return { ok: true, message: "daemon was not running" };
272
355
  }
356
+ if (lock.purpose === "pairing") return { ok: false, message: "Telegram pairing is active; cancel setup in its terminal first." };
273
357
  try {
274
358
  process.kill(lock.pid, "SIGTERM");
275
359
  } catch (err) {
@@ -277,13 +361,14 @@ export async function stopDaemon(): Promise<{ ok: boolean; message: string }> {
277
361
  }
278
362
  const stopped = await waitFor(async () => !isPidAlive(lock.pid), 3_000);
279
363
  if (!stopped) return { ok: false, message: `daemon (pid ${lock.pid}) did not exit within 3s` };
280
- await fs.unlink(notifyDaemonLockPath()).catch(() => {});
364
+ await removeStoppedLock(lock);
281
365
  const caveat = owner.verified ? "" : " (start time unverifiable on this host — PID reuse could not be ruled out)";
282
366
  return { ok: true, message: `daemon stopped (was pid ${lock.pid})${caveat}` };
283
367
  }
284
368
 
285
- export async function reloadDaemon(spawnImpl: SpawnLike = defaultSpawn): Promise<{ ok: boolean; message: string }> {
286
- const stopRes = await stopDaemon();
369
+ export async function reloadDaemon(spawnImpl: SpawnLike = defaultSpawn, stopImpl: typeof stopDaemon = stopDaemon): Promise<{ ok: boolean; message: string }> {
370
+ const stopRes = await stopImpl();
371
+ if (!stopRes.ok) return stopRes;
287
372
  const startRes = await startDaemon(spawnImpl);
288
373
  return { ok: startRes.ok, message: `${stopRes.message}; ${startRes.message}` };
289
374
  }