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.
- package/CHANGELOG.md +51 -1
- package/README.ja.md +62 -33
- package/README.ko.md +65 -35
- package/README.md +61 -32
- package/README.zh.md +64 -35
- package/package.json +1 -1
- package/src/agent/config-schema.ts +20 -7
- package/src/agent/debug-session.ts +7 -3
- package/src/agent/engine.ts +46 -102
- package/src/agent/loop-guards.ts +11 -13
- package/src/agent/notify/daemon-control.ts +109 -24
- package/src/agent/notify/discord-api.ts +148 -0
- package/src/agent/notify/discord-daemon.ts +443 -0
- package/src/agent/notify/paths.ts +1 -1
- package/src/agent/notify/session-discovery.ts +16 -0
- package/src/agent/notify/session-endpoint.ts +43 -25
- package/src/agent/notify/slack-api.ts +177 -0
- package/src/agent/notify/slack-daemon.ts +361 -0
- package/src/agent/notify/telegram-daemon.ts +215 -83
- package/src/agent/state.ts +13 -1
- package/src/ai/model-discovery.ts +13 -21
- package/src/autopilot.ts +14 -5
- package/src/cli/runner.ts +4 -4
- package/src/commands/approve.ts +21 -16
- package/src/commands/daemon.ts +5 -3
- package/src/commands/launch/agents-slash.ts +8 -3
- package/src/commands/launch.ts +36 -44
- package/src/commands/notify.ts +215 -83
- package/src/commands/ralplan.ts +2 -1
- package/src/commands/team.ts +17 -12
- package/src/commands/ultragoal.ts +2 -1
package/src/agent/engine.ts
CHANGED
|
@@ -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
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
995
|
-
//
|
|
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
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1080
|
-
});
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
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
|
-
//
|
|
1449
|
-
//
|
|
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
|
|
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 =
|
|
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
|
}
|
package/src/agent/loop-guards.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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,
|
|
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
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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:
|
|
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
|
|
323
|
+
const ready = await waitImpl(async () => {
|
|
258
324
|
const lock = await readDaemonLock();
|
|
259
|
-
return Boolean(lock && isPidAlive(lock.pid));
|
|
260
|
-
},
|
|
261
|
-
if (!ready) return { ok: false, message: `daemon did not
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|