borgmcp 5.3.0 → 5.4.1
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 +1 -0
- package/dist/agent-runtime.d.ts.map +1 -1
- package/dist/agent-runtime.js +6 -0
- 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/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/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/RELEASING.md +2 -2
- package/docs/SESSION_IDENTITY.md +15 -0
- package/package.json +2 -2
- package/src/agent-runtime.ts +6 -0
- package/src/agent-session-identity.ts +98 -0
- package/src/index.ts +10 -5
- package/src/lifecycle-log-guard.ts +70 -26
- package/src/opencode-drone.ts +14 -0
- package/src/regen-format.ts +1 -1
- package/src/regen.ts +7 -1
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Agent Session Identity
|
|
2
|
+
|
|
3
|
+
ARRIVAL is announced once for each known agent session and cube/drone pair, using local lifecycle state. Changing the ARRIVAL wording or reconnecting the MCP transport does not create another announcement for that identity. A different session announces immediately, without a time window. Resuming an already announced session remains suppressed. This state does not affect credentials, authorization, stream ownership, or liveness.
|
|
4
|
+
|
|
5
|
+
The startup instruction itself remains process-local: reconnecting may display it again. Calling `borg_log` with ARRIVAL applies the persistent identity check. Unknown identity always announces rather than concealing a possible restart. READY suppression is unchanged.
|
|
6
|
+
|
|
7
|
+
## Harness Coverage
|
|
8
|
+
|
|
9
|
+
- **Claude Code:** the latest SessionStart hook observed for this launch. Borg puts a random correlation in the launch environment. `borg-regen` stores that correlation, the hook's `session_id`, and its observation time in the worktree's `.borgmcp/claude-session.json`. A reconnect with the same correlation reads the same observation; a successful hook on resume replaces it. Missing, invalid, unreadable, or prior-launch state is unknown. Claude identity can be stale after a resume whose SessionStart hook failed.
|
|
10
|
+
- **Codex:** exactly one non-ephemeral user thread, excluding subagents, in this launch's Borg-owned app-server. Zero or multiple candidates, an unreadable thread, or an unavailable socket is unknown. No recency guess identifies a session.
|
|
11
|
+
- **OpenCode:** the existing launch-correlation binding, rechecked against session metadata. Missing or changed binding, an unseen root session, duplicated launch metadata, or failed inspection is unknown. The identity reader does not follow the wake injector's `/new` selection heuristic.
|
|
12
|
+
|
|
13
|
+
`borg_stream-status` reports the identity source, the harness-prefixed session id, and the age of the observation in its text response. For Claude this age is measured from the hook, not the MCP child's start. Age is diagnostic only: there is no expiry heuristic. For Codex and OpenCode the observation is a fresh query. Unknown results report their reason.
|
|
14
|
+
|
|
15
|
+
These identifiers denote harness conversations, not operating-system processes. Restarting a harness into a new conversation produces a new identity; explicitly resuming the same conversation retains its identity when ownership is known. Session identifiers are not secrets or authorization inputs.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "borgmcp",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.4.1",
|
|
4
4
|
"description": "Coordinate AI coding agents in shared cubes. Works with Claude Code, Codex, and OpenCode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
74
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
75
|
-
"borgmcp-shared": "2.
|
|
75
|
+
"borgmcp-shared": "2.2.0",
|
|
76
76
|
"chalk": "^5.3.0",
|
|
77
77
|
"prompts": "^2.4.2",
|
|
78
78
|
"which": "^4.0.0"
|
package/src/agent-runtime.ts
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { BORG_STATE_ROOT_ENV } from './private-root.js';
|
|
9
|
+
import { randomBytes } from 'node:crypto';
|
|
9
10
|
|
|
10
11
|
export type AgentKind = 'claude' | 'codex' | 'opencode';
|
|
11
12
|
|
|
12
13
|
/** Pinned into MCP-child environments by Borg launch paths. */
|
|
13
14
|
export const BORG_AGENT_KIND_ENV = 'BORG_AGENT_KIND';
|
|
15
|
+
export const BORG_CLAUDE_LAUNCH_CORRELATION_ENV = 'BORG_CLAUDE_LAUNCH_CORRELATION';
|
|
14
16
|
/** Transport capability only — never use it as the primary CLI identity. */
|
|
15
17
|
export const BORG_CODEX_REMOTE_WAKE_ENV = 'BORG_CODEX_REMOTE_WAKE';
|
|
16
18
|
/** Legacy OpenCode runtime marker, retained for installed-config compatibility. */
|
|
@@ -52,7 +54,11 @@ export function withAgentRuntimeEnv(
|
|
|
52
54
|
delete next[BORG_AGENT_KIND_ENV];
|
|
53
55
|
delete next[BORG_CODEX_REMOTE_WAKE_ENV];
|
|
54
56
|
delete next[BORG_OPENCODE_ENV];
|
|
57
|
+
delete next[BORG_CLAUDE_LAUNCH_CORRELATION_ENV];
|
|
55
58
|
next[BORG_AGENT_KIND_ENV] = agentKind;
|
|
59
|
+
if (agentKind === 'claude') {
|
|
60
|
+
next[BORG_CLAUDE_LAUNCH_CORRELATION_ENV] = randomBytes(32).toString('base64url');
|
|
61
|
+
}
|
|
56
62
|
if (agentKind === 'opencode') next[BORG_OPENCODE_ENV] = '1';
|
|
57
63
|
return next;
|
|
58
64
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { BORG_CLAUDE_LAUNCH_CORRELATION_ENV, resolveReportableSessionAgentKind } from './agent-runtime.js';
|
|
5
|
+
import { findProjectRoot } from './cubes.js';
|
|
6
|
+
import { ensurePrivateBorgConfigRoot } from './private-root.js';
|
|
7
|
+
import { CodexAppServerClient } from './codex-app-server.js';
|
|
8
|
+
import { codexAppServerSocketFromEnv, isCodexSubagentSource } from './codex-wake-resolve.js';
|
|
9
|
+
import { resolveOpenCodeAgentSessionId } from './opencode-drone.js';
|
|
10
|
+
|
|
11
|
+
export type AgentSessionIdentity =
|
|
12
|
+
| { kind: 'known'; id: string; source: string; observedAt: string }
|
|
13
|
+
| { kind: 'unknown'; reason: string };
|
|
14
|
+
|
|
15
|
+
function validId(value: unknown): value is string {
|
|
16
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 512 && !/[\s\x00-\x1f\x7f]/.test(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function claudeCorrelation(env: NodeJS.ProcessEnv): string | null {
|
|
20
|
+
const value = env[BORG_CLAUDE_LAUNCH_CORRELATION_ENV];
|
|
21
|
+
if (!value || !/^[A-Za-z0-9_-]{43}$/.test(value)) return null;
|
|
22
|
+
return Buffer.from(value, 'base64url').toString('base64url') === value ? value : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Identity only: no credential, authorization, liveness, or expiry semantics. */
|
|
26
|
+
export async function recordClaudeSessionStart(
|
|
27
|
+
payload: string,
|
|
28
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
29
|
+
worktree = findProjectRoot(),
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
const correlation = claudeCorrelation(env);
|
|
32
|
+
if (!correlation) return;
|
|
33
|
+
let sessionId: unknown;
|
|
34
|
+
try { sessionId = JSON.parse(payload)?.session_id; } catch { /* Persist unknown rather than retain an old id. */ }
|
|
35
|
+
const root = join(worktree, '.borgmcp');
|
|
36
|
+
await ensurePrivateBorgConfigRoot(root);
|
|
37
|
+
try { await writeFile(join(root, '.gitignore'), '*\n', { flag: 'wx', mode: 0o600 }); }
|
|
38
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; }
|
|
39
|
+
const path = join(root, 'claude-session.json');
|
|
40
|
+
const temporary = `${path}.${randomBytes(16).toString('hex')}.tmp`;
|
|
41
|
+
try {
|
|
42
|
+
await writeFile(temporary, JSON.stringify({
|
|
43
|
+
correlation, session_id: validId(sessionId) ? sessionId : null,
|
|
44
|
+
observedAt: new Date().toISOString(),
|
|
45
|
+
}) + '\n', { flag: 'wx', mode: 0o600 });
|
|
46
|
+
await rename(temporary, path);
|
|
47
|
+
} finally {
|
|
48
|
+
await unlink(temporary).catch((error: NodeJS.ErrnoException) => { if (error.code !== 'ENOENT') throw error; });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function resolveAgentSessionIdentity(
|
|
53
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
54
|
+
worktree = findProjectRoot(),
|
|
55
|
+
): Promise<AgentSessionIdentity> {
|
|
56
|
+
const kind = resolveReportableSessionAgentKind(env);
|
|
57
|
+
try {
|
|
58
|
+
if (kind === 'claude') {
|
|
59
|
+
const correlation = claudeCorrelation(env);
|
|
60
|
+
if (!correlation) return { kind: 'unknown', reason: 'claude-launch-correlation-missing' };
|
|
61
|
+
const record = JSON.parse(await readFile(join(worktree, '.borgmcp', 'claude-session.json'), 'utf8'));
|
|
62
|
+
if (record?.correlation !== correlation) return { kind: 'unknown', reason: 'claude-launch-correlation-mismatch' };
|
|
63
|
+
if (!validId(record.session_id) || typeof record.observedAt !== 'string' || !Number.isFinite(Date.parse(record.observedAt))) {
|
|
64
|
+
return { kind: 'unknown', reason: 'claude-session-start-invalid' };
|
|
65
|
+
}
|
|
66
|
+
// Last observed hook, not proof of freshness: a skipped resume hook is
|
|
67
|
+
// undetectable without a future per-call harness session handoff.
|
|
68
|
+
return { kind: 'known', id: `claude:${record.session_id}`, source: 'claude-session-start', observedAt: record.observedAt };
|
|
69
|
+
}
|
|
70
|
+
if (kind === 'codex') {
|
|
71
|
+
const socket = codexAppServerSocketFromEnv(env);
|
|
72
|
+
if (!socket) return { kind: 'unknown', reason: 'codex-launch-socket-missing' };
|
|
73
|
+
const client = new CodexAppServerClient(socket);
|
|
74
|
+
try {
|
|
75
|
+
await client.connect();
|
|
76
|
+
const candidates: string[] = [];
|
|
77
|
+
for (const id of await client.loadedThreadIds()) {
|
|
78
|
+
const thread = await client.readThread(id);
|
|
79
|
+
// An unreadable candidate could be another user thread; do not guess.
|
|
80
|
+
if (!thread) return { kind: 'unknown', reason: 'codex-thread-unreadable' };
|
|
81
|
+
if (thread.ephemeral !== true && !isCodexSubagentSource(thread.source) &&
|
|
82
|
+
(thread.threadSource === undefined || thread.threadSource === 'user')) candidates.push(thread.id);
|
|
83
|
+
}
|
|
84
|
+
if (candidates.length !== 1 || !validId(candidates[0])) return { kind: 'unknown', reason: 'codex-user-thread-not-unique' };
|
|
85
|
+
return { kind: 'known', id: `codex:${candidates[0]}`, source: 'codex-launch-thread', observedAt: new Date().toISOString() };
|
|
86
|
+
} finally { client.close(); }
|
|
87
|
+
}
|
|
88
|
+
if (kind === 'opencode') {
|
|
89
|
+
const id = await resolveOpenCodeAgentSessionId();
|
|
90
|
+
return validId(id)
|
|
91
|
+
? { kind: 'known', id: `opencode:${id}`, source: 'opencode-launch-binding', observedAt: new Date().toISOString() }
|
|
92
|
+
: { kind: 'unknown', reason: 'opencode-launch-binding-unavailable-or-changed' };
|
|
93
|
+
}
|
|
94
|
+
return { kind: 'unknown', reason: 'agent-harness-unknown' };
|
|
95
|
+
} catch {
|
|
96
|
+
return { kind: 'unknown', reason: `${kind ?? 'agent'}-session-source-unavailable` };
|
|
97
|
+
}
|
|
98
|
+
}
|
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/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) {
|