borgmcp 5.2.0 → 5.4.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.
- package/dist/agent-runtime.d.ts +3 -4
- package/dist/agent-runtime.d.ts.map +1 -1
- package/dist/agent-runtime.js +9 -5
- package/dist/agent-runtime.js.map +1 -1
- package/dist/agent-session-identity.d.ts +13 -0
- package/dist/agent-session-identity.d.ts.map +1 -0
- package/dist/agent-session-identity.js +106 -0
- package/dist/agent-session-identity.js.map +1 -0
- package/dist/assimilate-cmd.d.ts +1 -11
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +37 -48
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/assimilate-deps.d.ts.map +1 -1
- package/dist/assimilate-deps.js +1 -4
- package/dist/assimilate-deps.js.map +1 -1
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +36 -77
- package/dist/claude.js.map +1 -1
- package/dist/codex-app-server.d.ts +4 -6
- package/dist/codex-app-server.d.ts.map +1 -1
- package/dist/codex-app-server.js +4 -25
- package/dist/codex-app-server.js.map +1 -1
- package/dist/codex-app-wake.d.ts +8 -10
- package/dist/codex-app-wake.d.ts.map +1 -1
- package/dist/codex-app-wake.js +49 -66
- package/dist/codex-app-wake.js.map +1 -1
- package/dist/codex-launch.d.ts +15 -24
- package/dist/codex-launch.d.ts.map +1 -1
- package/dist/codex-launch.js +29 -55
- package/dist/codex-launch.js.map +1 -1
- package/dist/codex-remote.d.ts +15 -8
- package/dist/codex-remote.d.ts.map +1 -1
- package/dist/codex-remote.js +71 -70
- package/dist/codex-remote.js.map +1 -1
- package/dist/codex-wake-resolve.d.ts +12 -31
- package/dist/codex-wake-resolve.d.ts.map +1 -1
- package/dist/codex-wake-resolve.js +21 -38
- package/dist/codex-wake-resolve.js.map +1 -1
- package/dist/config-utils.js +2 -2
- package/dist/config-utils.js.map +1 -1
- package/dist/cubes.d.ts +0 -17
- package/dist/cubes.d.ts.map +1 -1
- package/dist/cubes.js +0 -81
- package/dist/cubes.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -5
- package/dist/index.js.map +1 -1
- package/dist/lifecycle-log-guard.d.ts +6 -8
- package/dist/lifecycle-log-guard.d.ts.map +1 -1
- package/dist/lifecycle-log-guard.js +74 -22
- package/dist/lifecycle-log-guard.js.map +1 -1
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +4 -1
- package/dist/log-stream.js.map +1 -1
- package/dist/opencode-drone.d.ts +2 -0
- package/dist/opencode-drone.d.ts.map +1 -1
- package/dist/opencode-drone.js +16 -0
- package/dist/opencode-drone.js.map +1 -1
- package/dist/regen-format.js +1 -1
- package/dist/regen-format.js.map +1 -1
- package/dist/regen.js +11 -1
- package/dist/regen.js.map +1 -1
- package/docs/SESSION_IDENTITY.md +15 -0
- package/package.json +1 -1
- package/src/agent-runtime.ts +9 -5
- package/src/agent-session-identity.ts +98 -0
- package/src/assimilate-cmd.ts +35 -58
- package/src/assimilate-deps.ts +0 -4
- package/src/claude.ts +34 -81
- package/src/codex-app-server.ts +8 -25
- package/src/codex-app-wake.ts +46 -73
- package/src/codex-launch.ts +43 -76
- package/src/codex-remote.ts +90 -82
- package/src/codex-wake-resolve.ts +26 -47
- package/src/config-utils.ts +2 -2
- package/src/cubes.ts +2 -100
- package/src/index.ts +10 -5
- package/src/lifecycle-log-guard.ts +70 -26
- package/src/log-stream.ts +4 -1
- package/src/opencode-drone.ts +14 -0
- package/src/regen-format.ts +1 -1
- package/src/regen.ts +7 -1
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* gh#855 — pure helpers for FRESH codex wake-target re-resolution.
|
|
3
3
|
*
|
|
4
|
-
* Root cause of codex deaf-when-idle:
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* authoritative about its OWN live app-server socket — the socket is injected
|
|
4
|
+
* Root cause of codex deaf-when-idle: stale thread selection could leave the
|
|
5
|
+
* drone permanently deaf. The waking borg-mcp child is authoritative about its
|
|
6
|
+
* OWN live app-server socket — the socket is injected
|
|
8
7
|
* into the child's pinned env at spawn (codex-remote.ts, via the #851 `-c
|
|
9
8
|
* mcp_servers.borg.env.X` channel) — and re-resolves the loaded thread FRESH on
|
|
10
9
|
* every wake (loadedThreadIds is a re-runnable RPC).
|
|
@@ -15,7 +14,7 @@
|
|
|
15
14
|
/** Pinned-env var carrying THIS drone's live app-server socket (set at spawn). */
|
|
16
15
|
export const BORG_CODEX_APP_SERVER_SOCKET_ENV = 'BORG_CODEX_APP_SERVER_SOCKET';
|
|
17
16
|
|
|
18
|
-
/** The live app-server socket for this borg-mcp child
|
|
17
|
+
/** The live app-server socket for this borg-mcp child. */
|
|
19
18
|
export function codexAppServerSocketFromEnv(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
20
19
|
const v = env[BORG_CODEX_APP_SERVER_SOCKET_ENV];
|
|
21
20
|
return v && v.length > 0 ? v : null;
|
|
@@ -37,6 +36,16 @@ export interface CodexThreadInfo {
|
|
|
37
36
|
id: string;
|
|
38
37
|
cwd?: string;
|
|
39
38
|
updatedAt?: number;
|
|
39
|
+
source?: unknown;
|
|
40
|
+
ephemeral?: boolean;
|
|
41
|
+
threadSource?: string;
|
|
42
|
+
preview?: string;
|
|
43
|
+
turns?: unknown[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isCodexSubagentSource(source: unknown): boolean {
|
|
47
|
+
if (!source || typeof source !== 'object' || Array.isArray(source)) return false;
|
|
48
|
+
return 'subagent' in source || 'subAgent' in source;
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
/**
|
|
@@ -44,15 +53,22 @@ export interface CodexThreadInfo {
|
|
|
44
53
|
* is fresh-per-launch / single-session, so the common case is exactly one loaded
|
|
45
54
|
* thread. When more than one is loaded, prefer the thread whose cwd matches this
|
|
46
55
|
* drone's working directory (sibling worktrees have distinct cwds), then the
|
|
47
|
-
* newest by updatedAt
|
|
56
|
+
* newest by updatedAt, without preferring an empty thread over one with turns.
|
|
57
|
+
* System, ephemeral and subagent threads are never candidates. No loaded thread → null (no wake
|
|
48
58
|
* this cycle; the next wake retries, so a transient empty list never causes
|
|
49
59
|
* permanent deafness).
|
|
50
60
|
*/
|
|
51
61
|
export function pickFreshThread(threads: CodexThreadInfo[], opts: { cwd: string }): string | null {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
const rootThreads = threads.filter((thread) =>
|
|
63
|
+
!isCodexSubagentSource(thread.source) && thread.ephemeral !== true &&
|
|
64
|
+
(thread.threadSource === undefined || thread.threadSource === 'user'));
|
|
65
|
+
if (rootThreads.length === 0) return null;
|
|
66
|
+
if (rootThreads.length === 1) return rootThreads[0].id;
|
|
67
|
+
const cwdMatches = rootThreads.filter((t) => t.cwd === opts.cwd);
|
|
68
|
+
let pool = cwdMatches.length > 0 ? cwdMatches : rootThreads;
|
|
69
|
+
if (pool.some((thread) => (thread.turns?.length ?? 0) > 0)) {
|
|
70
|
+
pool = pool.filter((thread) => (thread.turns?.length ?? 0) > 0 || !!thread.preview);
|
|
71
|
+
}
|
|
56
72
|
let best = pool[0];
|
|
57
73
|
for (const t of pool) {
|
|
58
74
|
if ((t.updatedAt ?? 0) > (best.updatedAt ?? 0)) best = t;
|
|
@@ -60,43 +76,6 @@ export function pickFreshThread(threads: CodexThreadInfo[], opts: { cwd: string
|
|
|
60
76
|
return best.id;
|
|
61
77
|
}
|
|
62
78
|
|
|
63
|
-
/**
|
|
64
|
-
* Pure prune: drop wake-target entries whose app-server socket is positively
|
|
65
|
-
* dead (liveness === false), so the file self-heals. Keeps alive (true) and
|
|
66
|
-
* indeterminate (null) entries — false-deaf-avoidance, mirroring
|
|
67
|
-
* checkCodexBridgeHealthy's tri-state. Returns the surviving map + whether
|
|
68
|
-
* anything changed (so the caller writes only on change).
|
|
69
|
-
*/
|
|
70
|
-
export function pruneDeadWakeTargets<T extends { socketPath: string }>(
|
|
71
|
-
targets: Record<string, T>,
|
|
72
|
-
socketLiveness: (socketPath: string) => boolean | null
|
|
73
|
-
): { targets: Record<string, T>; changed: boolean } {
|
|
74
|
-
const out: Record<string, T> = {};
|
|
75
|
-
let changed = false;
|
|
76
|
-
for (const [key, t] of Object.entries(targets)) {
|
|
77
|
-
if (socketLiveness(t.socketPath) === false) {
|
|
78
|
-
changed = true;
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
out[key] = t;
|
|
82
|
-
}
|
|
83
|
-
return { targets: out, changed };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Whether the freshly-resolved target differs from what's already recorded —
|
|
88
|
-
* so the self-healing cache write happens only on change (no file thrash on a
|
|
89
|
-
* busy cube re-resolving the same socket+thread every wake).
|
|
90
|
-
*/
|
|
91
|
-
export function wakeTargetChanged(
|
|
92
|
-
existing: { socketPath: string; threadId: string } | null,
|
|
93
|
-
fresh: { socketPath: string; threadId: string }
|
|
94
|
-
): boolean {
|
|
95
|
-
return (
|
|
96
|
-
!existing || existing.socketPath !== fresh.socketPath || existing.threadId !== fresh.threadId
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
79
|
// ─── gh#857 Phase 2: durable retry + heartbeat (pure helpers) ───────────────
|
|
101
80
|
|
|
102
81
|
/** Base backoff for the first retry of a dropped/deferred wake. */
|
package/src/config-utils.ts
CHANGED
|
@@ -1293,8 +1293,8 @@ export function addCodexMcpServer(): void {
|
|
|
1293
1293
|
// Codex MCP config when the operator has explicitly set it.
|
|
1294
1294
|
const apiUrl = process.env.BORG_API_URL;
|
|
1295
1295
|
// Identity is durable configuration; remote wake is a per-launch
|
|
1296
|
-
// transport capability
|
|
1297
|
-
//
|
|
1296
|
+
// transport capability pinned by the Borg-owned app-server. Do not persist
|
|
1297
|
+
// that transport marker in the user's static Codex configuration.
|
|
1298
1298
|
const apiUrlEnvArg = apiUrl ? ` --env BORG_API_URL=${shellQuote(apiUrl)}` : '';
|
|
1299
1299
|
const stateRoot = process.env[BORG_STATE_ROOT_ENV];
|
|
1300
1300
|
const stateRootEnvArg = stateRoot
|
package/src/cubes.ts
CHANGED
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
import { existsSync } from 'node:fs';
|
|
19
19
|
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
20
20
|
import { dirname, join, resolve } from 'node:path';
|
|
21
|
-
import { pruneDeadWakeTargets } from './codex-wake-resolve.js';
|
|
22
21
|
import { borgConfigRoot } from './private-root.js';
|
|
23
22
|
import {
|
|
24
23
|
getActiveSeatCredential,
|
|
@@ -38,15 +37,14 @@ import {
|
|
|
38
37
|
// The unified 0600 seat store (seats.ts / seats.json) is now the SOLE home of the
|
|
39
38
|
// per-worktree ACTIVE-CUBE seat map: each seat = credential + worktree binding +
|
|
40
39
|
// display as ONE atomic record. cubes.json no longer holds a seat map — the
|
|
41
|
-
// functions below are thin adapters over seats.ts. Only launch.json
|
|
42
|
-
//
|
|
40
|
+
// functions below are thin adapters over seats.ts. Only launch.json and the
|
|
41
|
+
// inbox tree remain owned by this module.
|
|
43
42
|
|
|
44
43
|
/** Re-exported from seats.ts for call-site parity (the retired cross-store name). */
|
|
45
44
|
export type { SeatExpectation as ExpectedBinding } from './seats.js';
|
|
46
45
|
|
|
47
46
|
const CUBES_DIR = borgConfigRoot();
|
|
48
47
|
const LAUNCH_FILE = join(CUBES_DIR, 'launch.json');
|
|
49
|
-
const CODEX_WAKE_TARGETS_FILE = join(CUBES_DIR, 'codex-wake-targets.json');
|
|
50
48
|
const INBOX_DIR = join(CUBES_DIR, 'inboxes');
|
|
51
49
|
|
|
52
50
|
export type BorgCli = 'claude' | 'codex' | 'opencode';
|
|
@@ -206,16 +204,6 @@ function unreadableStateError(filePath: string): Error {
|
|
|
206
204
|
return new Error(`Borg state file is unreadable; refusing to overwrite it: ${filePath}`);
|
|
207
205
|
}
|
|
208
206
|
|
|
209
|
-
export interface CodexWakeTargetRecord {
|
|
210
|
-
threadId: string;
|
|
211
|
-
socketPath: string;
|
|
212
|
-
updatedAt: string;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
interface CodexWakeTargetsFile {
|
|
216
|
-
targets: Record<string, CodexWakeTargetRecord>;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
207
|
/**
|
|
220
208
|
* Walk up from cwd looking for a .git directory. If found, return that
|
|
221
209
|
* directory. If not found by filesystem root, return the original cwd.
|
|
@@ -369,44 +357,6 @@ async function writeLaunchFile(data: LaunchFile): Promise<void> {
|
|
|
369
357
|
await atomicWriteFile(LAUNCH_FILE, JSON.stringify(data, null, 2) + '\n');
|
|
370
358
|
}
|
|
371
359
|
|
|
372
|
-
function codexWakeTargetKey(cubeId: string, droneId: string): string {
|
|
373
|
-
if (!UUID_RE.test(cubeId)) throw new Error(`Invalid cubeId: ${cubeId}`);
|
|
374
|
-
if (!UUID_RE.test(droneId)) throw new Error(`Invalid droneId: ${droneId}`);
|
|
375
|
-
return `${cubeId}:${droneId}`;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
function isCodexWakeTargetsFile(data: any): data is CodexWakeTargetsFile {
|
|
379
|
-
return (
|
|
380
|
-
data !== null &&
|
|
381
|
-
typeof data === 'object' &&
|
|
382
|
-
typeof data.targets === 'object' &&
|
|
383
|
-
data.targets !== null &&
|
|
384
|
-
!Array.isArray(data.targets)
|
|
385
|
-
);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
async function readCodexWakeTargetsFile(): Promise<StateFileRead<CodexWakeTargetsFile>> {
|
|
389
|
-
let raw: string;
|
|
390
|
-
try {
|
|
391
|
-
raw = await readFile(CODEX_WAKE_TARGETS_FILE, 'utf8');
|
|
392
|
-
} catch (error: any) {
|
|
393
|
-
if (error?.code === 'ENOENT') return null;
|
|
394
|
-
throw error;
|
|
395
|
-
}
|
|
396
|
-
try {
|
|
397
|
-
const parsed = JSON.parse(raw);
|
|
398
|
-
return isCodexWakeTargetsFile(parsed) ? parsed : UNREADABLE_STATE;
|
|
399
|
-
} catch {
|
|
400
|
-
return UNREADABLE_STATE;
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
async function writeCodexWakeTargetsFile(data: CodexWakeTargetsFile): Promise<void> {
|
|
405
|
-
// atomicWriteFile handles the mkdir + 0o600 mode, and the temp+rename keeps
|
|
406
|
-
// a concurrent reader from seeing a half-written file (gh#894, gh#901).
|
|
407
|
-
await atomicWriteFile(CODEX_WAKE_TARGETS_FILE, JSON.stringify(data, null, 2) + '\n');
|
|
408
|
-
}
|
|
409
|
-
|
|
410
360
|
/**
|
|
411
361
|
* Get the currently-active cube for the current project, or null if not
|
|
412
362
|
* assimilated in this project. Entries written by older client versions
|
|
@@ -749,51 +699,3 @@ export async function setProjectCliPreference(cli: BorgCli, dir?: string): Promi
|
|
|
749
699
|
next.projects[findProjectRoot(dir)] = { cli };
|
|
750
700
|
await writeLaunchFile(next);
|
|
751
701
|
}
|
|
752
|
-
|
|
753
|
-
export async function setCodexWakeTarget(
|
|
754
|
-
cubeId: string,
|
|
755
|
-
droneId: string,
|
|
756
|
-
target: Omit<CodexWakeTargetRecord, 'updatedAt'>
|
|
757
|
-
): Promise<void> {
|
|
758
|
-
const existing = await readCodexWakeTargetsFile();
|
|
759
|
-
if (existing === UNREADABLE_STATE) throw unreadableStateError(CODEX_WAKE_TARGETS_FILE);
|
|
760
|
-
const next = existing ?? { targets: {} };
|
|
761
|
-
next.targets[codexWakeTargetKey(cubeId, droneId)] = {
|
|
762
|
-
...target,
|
|
763
|
-
updatedAt: new Date().toISOString(),
|
|
764
|
-
};
|
|
765
|
-
await writeCodexWakeTargetsFile(next);
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
export async function getCodexWakeTarget(
|
|
769
|
-
cubeId: string,
|
|
770
|
-
droneId: string
|
|
771
|
-
): Promise<CodexWakeTargetRecord | null> {
|
|
772
|
-
const existing = await readCodexWakeTargetsFile();
|
|
773
|
-
if (existing === UNREADABLE_STATE) throw unreadableStateError(CODEX_WAKE_TARGETS_FILE);
|
|
774
|
-
if (!existing) return null;
|
|
775
|
-
const target = existing.targets[codexWakeTargetKey(cubeId, droneId)];
|
|
776
|
-
if (!target || typeof target.threadId !== 'string' || typeof target.socketPath !== 'string') {
|
|
777
|
-
return null;
|
|
778
|
-
}
|
|
779
|
-
return target;
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
/**
|
|
783
|
-
* gh#855: drop wake-target entries whose app-server socket is positively dead,
|
|
784
|
-
* so the file self-heals (stale dead-socket entries from crashed prior launches
|
|
785
|
-
* don't linger and mislead probeCodexBridgeArmed / health-beat). Pure prune
|
|
786
|
-
* decision lives in codex-wake-resolve.ts (false-deaf-avoidance: keeps alive +
|
|
787
|
-
* indeterminate); this is the thin read → prune → write-only-on-change glue.
|
|
788
|
-
* The liveness check is injected (claude.ts wires checkCodexBridgeHealthy) so
|
|
789
|
-
* cubes.ts stays free of the codex-remote dependency.
|
|
790
|
-
*/
|
|
791
|
-
export async function pruneDeadCodexWakeTargets(
|
|
792
|
-
socketLiveness: (socketPath: string) => boolean | null
|
|
793
|
-
): Promise<void> {
|
|
794
|
-
const existing = await readCodexWakeTargetsFile();
|
|
795
|
-
if (existing === UNREADABLE_STATE) throw unreadableStateError(CODEX_WAKE_TARGETS_FILE);
|
|
796
|
-
if (!existing) return;
|
|
797
|
-
const { targets, changed } = pruneDeadWakeTargets(existing.targets, socketLiveness);
|
|
798
|
-
if (changed) await writeCodexWakeTargetsFile({ ...existing, targets });
|
|
799
|
-
}
|
package/src/index.ts
CHANGED
|
@@ -100,6 +100,7 @@ import {
|
|
|
100
100
|
regenWakePathDroneLabel,
|
|
101
101
|
} from './regen-format.js';
|
|
102
102
|
import { startLogStream, getStreamStatus } from './log-stream.js';
|
|
103
|
+
import { resolveAgentSessionIdentity } from './agent-session-identity.js';
|
|
103
104
|
import { isMcpReadinessProbe } from './readiness-probe.js';
|
|
104
105
|
import { runMcpStartupServices } from './startup-services.js';
|
|
105
106
|
import { TOOL_MANIFEST, type ToolManifestEntry } from './tool-manifest.js';
|
|
@@ -913,8 +914,12 @@ export async function main() {
|
|
|
913
914
|
cubeName: active?.name ?? null,
|
|
914
915
|
humanAgo,
|
|
915
916
|
});
|
|
917
|
+
const agentSession = await resolveAgentSessionIdentity();
|
|
918
|
+
const identityText = agentSession.kind === 'known'
|
|
919
|
+
? `\n\nAgent session source: ${agentSession.source}\nSession id: ${JSON.stringify(agentSession.id)}\nIdentity age: ${Math.max(0, Date.now() - Date.parse(agentSession.observedAt))} ms (observed ${agentSession.observedAt})`
|
|
920
|
+
: `\n\nAgent session: unknown (${agentSession.reason}); ARRIVAL is not suppressed across reconnects.`;
|
|
916
921
|
return {
|
|
917
|
-
content: [{ type: 'text', text: silentInertWarning + text }],
|
|
922
|
+
content: [{ type: 'text', text: silentInertWarning + text + identityText }],
|
|
918
923
|
structuredContent: {
|
|
919
924
|
status,
|
|
920
925
|
wake_path: wakePath,
|
|
@@ -1076,16 +1081,16 @@ export async function main() {
|
|
|
1076
1081
|
seedDisplayIdentity(active);
|
|
1077
1082
|
const displayIdentity = renderDisplayIdentity(active);
|
|
1078
1083
|
const lifecycleSignal = lifecycleSignalForMessage(finalMessage);
|
|
1084
|
+
const agentSession = lifecycleSignal === 'arrival' ? await resolveAgentSessionIdentity() : undefined;
|
|
1079
1085
|
if (lifecycleSignal) {
|
|
1080
|
-
const decision = await shouldSuppressLifecycleLog(active, finalMessage);
|
|
1086
|
+
const decision = await shouldSuppressLifecycleLog(active, finalMessage, agentSession);
|
|
1081
1087
|
if (decision.suppress) {
|
|
1082
|
-
await recordLifecycleLog(active, finalMessage);
|
|
1083
1088
|
if (lifecycleSignal === 'arrival') markArrivalAnnouncedThisProcess();
|
|
1084
1089
|
return {
|
|
1085
1090
|
content: [
|
|
1086
1091
|
{
|
|
1087
1092
|
type: 'text',
|
|
1088
|
-
text: `Suppressed duplicate ${decision.signal?.toUpperCase()} lifecycle log for ${displayIdentity.droneLabel};
|
|
1093
|
+
text: `Suppressed duplicate ${decision.signal?.toUpperCase()} lifecycle log for ${displayIdentity.droneLabel}; this signal is already recorded locally for the current ${decision.signal === 'arrival' ? 'agent session' : 'idle period'}.`,
|
|
1089
1094
|
},
|
|
1090
1095
|
],
|
|
1091
1096
|
structuredContent: {
|
|
@@ -1110,7 +1115,7 @@ export async function main() {
|
|
|
1110
1115
|
serverTrustIdentity: active.serverTrustIdentity,
|
|
1111
1116
|
};
|
|
1112
1117
|
const result = await appendLog(active.sessionToken, active.apiUrl, finalMessage, appendOpts);
|
|
1113
|
-
await recordLifecycleLog(active, finalMessage);
|
|
1118
|
+
await recordLifecycleLog(active, finalMessage, agentSession);
|
|
1114
1119
|
if (lifecycleSignal === 'arrival') markArrivalAnnouncedThisProcess();
|
|
1115
1120
|
let recipientDrones: any[] = [];
|
|
1116
1121
|
if (result.entry.visibility === 'direct' && result.entry.recipient_drone_ids.length > 0) {
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
2
3
|
import { dirname, join } from 'node:path';
|
|
3
4
|
import { borgConfigRoot } from './private-root.js';
|
|
5
|
+
import type { AgentSessionIdentity } from './agent-session-identity.js';
|
|
4
6
|
|
|
5
7
|
const STATE_FILE = join(borgConfigRoot(), 'lifecycle-log-state.json');
|
|
6
|
-
const
|
|
8
|
+
const STATE_LOCK = `${STATE_FILE}.lock`;
|
|
9
|
+
const UNKNOWN_SESSION: AgentSessionIdentity = { kind: 'unknown', reason: 'identity-not-resolved' };
|
|
7
10
|
|
|
8
11
|
export type LifecycleSignal = 'arrival' | 'ready';
|
|
9
12
|
|
|
@@ -13,10 +16,7 @@ export interface LifecycleLogSubject {
|
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
interface LifecycleStateEntry {
|
|
16
|
-
|
|
17
|
-
message: string;
|
|
18
|
-
at: string;
|
|
19
|
-
};
|
|
19
|
+
arrivedSessionIds?: string[];
|
|
20
20
|
idleReady?: {
|
|
21
21
|
message: string;
|
|
22
22
|
open: boolean;
|
|
@@ -74,27 +74,62 @@ async function readState(): Promise<LifecycleStateRead> {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
async function writeState(state: LifecycleStateFile): Promise<void> {
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
const temporary = `${STATE_FILE}.${randomBytes(16).toString('hex')}.tmp`;
|
|
78
|
+
try {
|
|
79
|
+
await writeFile(temporary, JSON.stringify(state, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
80
|
+
await rename(temporary, STATE_FILE);
|
|
81
|
+
} finally {
|
|
82
|
+
await unlink(temporary).catch((error: NodeJS.ErrnoException) => { if (error.code !== 'ENOENT') throw error; });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Same bounded exclusive-lock and stale-reclaim pattern as local-server-cursor.
|
|
87
|
+
async function withLock<T>(operation: () => Promise<T>): Promise<T> {
|
|
88
|
+
await mkdir(dirname(STATE_LOCK), { recursive: true });
|
|
89
|
+
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
90
|
+
let handle;
|
|
91
|
+
try {
|
|
92
|
+
handle = await open(STATE_LOCK, 'wx', 0o600);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
|
95
|
+
try {
|
|
96
|
+
const metadata = await stat(STATE_LOCK);
|
|
97
|
+
if (Date.now() - metadata.mtimeMs > 30_000) {
|
|
98
|
+
await unlink(STATE_LOCK);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
} catch (inspectionError) {
|
|
102
|
+
if ((inspectionError as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
|
103
|
+
throw inspectionError;
|
|
104
|
+
}
|
|
105
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 10));
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
return await operation();
|
|
110
|
+
} finally {
|
|
111
|
+
await handle.close();
|
|
112
|
+
try {
|
|
113
|
+
await unlink(STATE_LOCK);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
throw new Error('Lifecycle log state is busy');
|
|
79
120
|
}
|
|
80
121
|
|
|
81
122
|
export function shouldSuppressLifecycleLogFromState(
|
|
82
123
|
message: string,
|
|
83
124
|
state: LifecycleStateEntry | undefined,
|
|
84
|
-
|
|
125
|
+
identity: AgentSessionIdentity = UNKNOWN_SESSION
|
|
85
126
|
): { suppress: boolean; signal: LifecycleSignal | null } {
|
|
86
127
|
const signal = lifecycleSignalForMessage(message);
|
|
87
128
|
if (!signal) return { suppress: false, signal: null };
|
|
88
129
|
|
|
89
130
|
if (signal === 'arrival') {
|
|
90
|
-
const lastArrivalAt = state?.lastArrival?.at
|
|
91
|
-
? new Date(state.lastArrival.at).getTime()
|
|
92
|
-
: NaN;
|
|
93
|
-
const isRecent =
|
|
94
|
-
Number.isFinite(lastArrivalAt) &&
|
|
95
|
-
nowMs - lastArrivalAt < ARRIVAL_DUPLICATE_WINDOW_MS;
|
|
96
131
|
return {
|
|
97
|
-
suppress: state?.
|
|
132
|
+
suppress: identity.kind === 'known' && state?.arrivedSessionIds?.includes(identity.id) === true,
|
|
98
133
|
signal,
|
|
99
134
|
};
|
|
100
135
|
}
|
|
@@ -107,26 +142,32 @@ export function shouldSuppressLifecycleLogFromState(
|
|
|
107
142
|
|
|
108
143
|
export async function shouldSuppressLifecycleLog(
|
|
109
144
|
subject: LifecycleLogSubject,
|
|
110
|
-
message: string
|
|
145
|
+
message: string,
|
|
146
|
+
identity: AgentSessionIdentity = UNKNOWN_SESSION
|
|
111
147
|
): Promise<{ suppress: boolean; signal: LifecycleSignal | null }> {
|
|
112
148
|
const state = await readState();
|
|
113
149
|
if (state === UNREADABLE_STATE) throw unreadableStateError();
|
|
114
150
|
return shouldSuppressLifecycleLogFromState(
|
|
115
151
|
message,
|
|
116
|
-
state.entries[stateKey(subject)]
|
|
152
|
+
state.entries[stateKey(subject)],
|
|
153
|
+
identity
|
|
117
154
|
);
|
|
118
155
|
}
|
|
119
156
|
|
|
120
157
|
export function nextLifecycleStateAfterLog(
|
|
121
158
|
message: string,
|
|
122
159
|
current: LifecycleStateEntry | undefined,
|
|
123
|
-
nowIso: string = new Date().toISOString()
|
|
160
|
+
nowIso: string = new Date().toISOString(),
|
|
161
|
+
identity: AgentSessionIdentity = UNKNOWN_SESSION
|
|
124
162
|
): LifecycleStateEntry {
|
|
125
163
|
const signal = lifecycleSignalForMessage(message);
|
|
126
164
|
if (signal === 'arrival') {
|
|
165
|
+
if (identity.kind === 'unknown' || current?.arrivedSessionIds?.includes(identity.id)) return current ?? {};
|
|
127
166
|
return {
|
|
128
167
|
...current,
|
|
129
|
-
|
|
168
|
+
// Retain one opaque id per announced session so resuming an older session
|
|
169
|
+
// also deduplicates. Pruning needs an explicit history-retention contract.
|
|
170
|
+
arrivedSessionIds: [...(current?.arrivedSessionIds ?? []), identity.id],
|
|
130
171
|
};
|
|
131
172
|
}
|
|
132
173
|
if (signal === 'ready') {
|
|
@@ -146,11 +187,14 @@ export function nextLifecycleStateAfterLog(
|
|
|
146
187
|
|
|
147
188
|
export async function recordLifecycleLog(
|
|
148
189
|
subject: LifecycleLogSubject,
|
|
149
|
-
message: string
|
|
190
|
+
message: string,
|
|
191
|
+
identity: AgentSessionIdentity = UNKNOWN_SESSION
|
|
150
192
|
): Promise<void> {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
193
|
+
await withLock(async () => {
|
|
194
|
+
const state = await readState();
|
|
195
|
+
if (state === UNREADABLE_STATE) throw unreadableStateError();
|
|
196
|
+
const key = stateKey(subject);
|
|
197
|
+
state.entries[key] = nextLifecycleStateAfterLog(message, state.entries[key], undefined, identity);
|
|
198
|
+
await writeState(state);
|
|
199
|
+
});
|
|
156
200
|
}
|
package/src/log-stream.ts
CHANGED
|
@@ -906,10 +906,13 @@ export async function streamOnce(
|
|
|
906
906
|
// OpenCode queue. A still-unread re-ping may reconcile the prior attempt;
|
|
907
907
|
// its source entry ID prevents a new nonce from resubmitting that prompt.
|
|
908
908
|
if (isReping) {
|
|
909
|
-
await injectOpenCode(
|
|
909
|
+
const openCodeDelivered = await injectOpenCode(
|
|
910
910
|
formatOpenCodeWakePrompt(line), deliveryId, true, ev.id,
|
|
911
911
|
() => hasPendingWakeEntry(active, ev.id),
|
|
912
912
|
);
|
|
913
|
+
if (!openCodeDelivered) {
|
|
914
|
+
wakeCodex(formatCodexWakePrompt(line), ev.wake_nonce, ev.id);
|
|
915
|
+
}
|
|
913
916
|
}
|
|
914
917
|
markEventPersisted(ev.id, ev.data?.created_at ?? '');
|
|
915
918
|
return 'persisted-skip';
|
package/src/opencode-drone.ts
CHANGED
|
@@ -1568,6 +1568,20 @@ export function settleOpenCodeEntry(sourceEntryId: string): void {
|
|
|
1568
1568
|
if (bindingChanged) persistCurrentBinding();
|
|
1569
1569
|
}
|
|
1570
1570
|
|
|
1571
|
+
/** Read the launch-selected identity without changing the injection binding. */
|
|
1572
|
+
export async function resolveOpenCodeAgentSessionId(): Promise<string | null> {
|
|
1573
|
+
const owner = state;
|
|
1574
|
+
if (!owner?.connected) return null;
|
|
1575
|
+
const binding = readBinding();
|
|
1576
|
+
if (!binding) return null;
|
|
1577
|
+
// The launch marker, unlike the injection binding, does not follow /new.
|
|
1578
|
+
const launch = await findLaunchSession(owner.launchIdentity);
|
|
1579
|
+
if (state !== owner || launch.kind !== 'found' || launch.session.id !== binding.sessionId ||
|
|
1580
|
+
launch.knownRootSessionIds.some((id) => !binding.knownRootSessionIds.includes(id))) return null;
|
|
1581
|
+
const current = readBinding();
|
|
1582
|
+
return current?.sessionId === binding.sessionId ? binding.sessionId : null;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1571
1585
|
export async function probeOpenCodeDroneArmed(): Promise<boolean | null> {
|
|
1572
1586
|
const owner = state;
|
|
1573
1587
|
if (!owner?.connected) return null;
|
package/src/regen-format.ts
CHANGED
|
@@ -251,7 +251,7 @@ export function markArrivalAnnouncedThisProcess(): void {
|
|
|
251
251
|
export function getDronePlaybook(handoverMode: HandoverMode = 'origin'): string {
|
|
252
252
|
const arrivalInstruction = arrivalAnnouncedThisProcess
|
|
253
253
|
? ''
|
|
254
|
-
: `\n**Required one-time startup setup:** If this instruction is visible, immediately call \`borg_log message="ARRIVAL: <your-label> (<your-role>) online on ${osHostname()}" to="broadcast"\` before unread-log triage. This is the sole exception to no-work log silence. Do not infer your startup state from peers' \`ARRIVAL:\` entries. After the post succeeds, the client suppresses this instruction until the MCP process restarts; an explicit \`/mcp\` reconnect may show it again.\n`;
|
|
254
|
+
: `\n**Required one-time startup setup:** If this instruction is visible, immediately call \`borg_log message="ARRIVAL: <your-label> (<your-role>) online on ${osHostname()}" to="broadcast"\` before unread-log triage. This is the sole exception to no-work log silence. Do not infer your startup state from peers' \`ARRIVAL:\` entries. After the post succeeds, the client suppresses this instruction until the MCP process restarts; an explicit \`/mcp\` reconnect may show it again. The log call suppresses repeat ARRIVAL for a known agent session across reconnects; unknown identity announces. Claude identity can be stale after a resume whose SessionStart hook failed. Inspect \`borg_stream-status\` for the identity source and observation age.\n`;
|
|
255
255
|
const reviewReadyRefs = handoverMode === 'local'
|
|
256
256
|
? '["HEAD"]'
|
|
257
257
|
: '["HEAD","origin/<branch>","origin/main"]';
|
package/src/regen.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
import { resolveSessionAgentKind } from './codex-app-wake.js';
|
|
29
29
|
import { resolveReportableSessionAgentKind, type AgentKind } from './agent-runtime.js';
|
|
30
30
|
import { handleVersionFlag } from './version.js';
|
|
31
|
+
import { recordClaudeSessionStart } from './agent-session-identity.js';
|
|
31
32
|
import {
|
|
32
33
|
BORG_LAUNCH_REMINDER_DISABLED_ENV,
|
|
33
34
|
BORG_SESSION_ENV,
|
|
@@ -61,7 +62,8 @@ async function readStdin(): Promise<string> {
|
|
|
61
62
|
|
|
62
63
|
async function main(): Promise<void> {
|
|
63
64
|
handleVersionFlag();
|
|
64
|
-
const
|
|
65
|
+
const hookPayload = await readStdin();
|
|
66
|
+
const hookSource = parseHookSource(hookPayload);
|
|
65
67
|
// gh#673 P1 (WI-4): the SessionStart orientation only activates in
|
|
66
68
|
// borg-launched sessions — a vanilla `claude` anywhere (including an
|
|
67
69
|
// assimilated repo) stays vanilla. Exit-0 no-op: a hook must never
|
|
@@ -95,6 +97,10 @@ async function main(): Promise<void> {
|
|
|
95
97
|
// session-scoped `/loop` + `ScheduleWakeup`, and the kickoff prompt is
|
|
96
98
|
// gone, so the lean orientation adds a "re-establish your wake path" note.
|
|
97
99
|
const source = hookSource;
|
|
100
|
+
if (resolveSessionAgentKind() === 'claude') {
|
|
101
|
+
try { await recordClaudeSessionStart(hookPayload); }
|
|
102
|
+
catch { process.stderr.write('Borg could not record the Claude SessionStart identity.\n'); }
|
|
103
|
+
}
|
|
98
104
|
|
|
99
105
|
const active = await getActiveCube();
|
|
100
106
|
if (!active) {
|