@1presence/bridge 0.20.0 → 0.22.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/accumulator.js +112 -0
- package/dist/claude.js +45 -14
- package/dist/index.js +104 -18
- package/dist/outbox.js +55 -0
- package/package.json +1 -1
- package/dist/sessions.js +0 -36
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.makeBridgeAccumulator = makeBridgeAccumulator;
|
|
4
|
+
exports.postSaveTurn = postSaveTurn;
|
|
5
|
+
function makeBridgeAccumulator() {
|
|
6
|
+
const state = {
|
|
7
|
+
assistantText: '',
|
|
8
|
+
toolCalls: [],
|
|
9
|
+
toolResults: {},
|
|
10
|
+
};
|
|
11
|
+
let textEmitted = false;
|
|
12
|
+
let turnTextEmitted = false;
|
|
13
|
+
function appendText(text) {
|
|
14
|
+
if (textEmitted && !turnTextEmitted)
|
|
15
|
+
state.assistantText += '\n\n';
|
|
16
|
+
state.assistantText += text;
|
|
17
|
+
turnTextEmitted = true;
|
|
18
|
+
textEmitted = true;
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
consume(event) {
|
|
22
|
+
const type = event['type'];
|
|
23
|
+
if (type === 'text') {
|
|
24
|
+
const t = event['text'];
|
|
25
|
+
if (t)
|
|
26
|
+
appendText(t);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (type === 'assistant') {
|
|
30
|
+
// New API turn — reset the per-turn flag so the next text emission
|
|
31
|
+
// gets a `\n\n` separator iff text was already emitted in a prior turn.
|
|
32
|
+
turnTextEmitted = false;
|
|
33
|
+
const msg = event['message'];
|
|
34
|
+
const content = msg?.['content'];
|
|
35
|
+
if (!Array.isArray(content))
|
|
36
|
+
return;
|
|
37
|
+
for (const block of content) {
|
|
38
|
+
if (block['type'] === 'text') {
|
|
39
|
+
const t = block['text'];
|
|
40
|
+
if (t)
|
|
41
|
+
appendText(t);
|
|
42
|
+
}
|
|
43
|
+
else if (block['type'] === 'tool_use') {
|
|
44
|
+
const id = block['id'];
|
|
45
|
+
const name = block['name'];
|
|
46
|
+
const input = block['input'] ?? {};
|
|
47
|
+
if (!id || !name)
|
|
48
|
+
continue;
|
|
49
|
+
const bareName = name.replace(/^mcp__1presence__/, '');
|
|
50
|
+
state.toolCalls.push({ id, name: bareName, input });
|
|
51
|
+
if (bareName === 'set_conversation_title') {
|
|
52
|
+
const raw = String(input['title'] ?? '').trim();
|
|
53
|
+
if (raw)
|
|
54
|
+
state.title = raw.charAt(0).toUpperCase() + raw.slice(1);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (type === 'user') {
|
|
61
|
+
const msg = event['message'];
|
|
62
|
+
const content = msg?.['content'];
|
|
63
|
+
if (!Array.isArray(content))
|
|
64
|
+
return;
|
|
65
|
+
for (const block of content) {
|
|
66
|
+
if (block['type'] !== 'tool_result')
|
|
67
|
+
continue;
|
|
68
|
+
const id = block['tool_use_id'];
|
|
69
|
+
const result = block['content'];
|
|
70
|
+
if (!id)
|
|
71
|
+
continue;
|
|
72
|
+
state.toolResults[id] = typeof result === 'string' ? result : JSON.stringify(result);
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
state() {
|
|
78
|
+
return state;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
async function postSaveTurn(gatewayHttp, token, record) {
|
|
83
|
+
let res;
|
|
84
|
+
try {
|
|
85
|
+
res = await fetch(`${gatewayHttp}/bridge/save-turn`, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: {
|
|
88
|
+
'Content-Type': 'application/json',
|
|
89
|
+
'Authorization': `Bearer ${token}`,
|
|
90
|
+
},
|
|
91
|
+
body: JSON.stringify({
|
|
92
|
+
sessionId: record.sessionId,
|
|
93
|
+
conversationId: record.conversationId,
|
|
94
|
+
userMessage: record.userMessage,
|
|
95
|
+
assistantText: record.assistantText,
|
|
96
|
+
toolCalls: record.toolCalls,
|
|
97
|
+
toolResults: record.toolResults,
|
|
98
|
+
...(record.title ? { title: record.title } : {}),
|
|
99
|
+
...(record.usage ? { usage: record.usage } : {}),
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
return { ok: false, finalized: false, status: 0, error: err.message };
|
|
105
|
+
}
|
|
106
|
+
if (!res.ok) {
|
|
107
|
+
const body = await res.text().catch(() => '');
|
|
108
|
+
return { ok: false, finalized: false, status: res.status, error: body.slice(0, 200) };
|
|
109
|
+
}
|
|
110
|
+
const data = await res.json().catch(() => ({}));
|
|
111
|
+
return { ok: true, finalized: data.finalized === true, status: res.status };
|
|
112
|
+
}
|
package/dist/claude.js
CHANGED
|
@@ -7,7 +7,6 @@ const child_process_1 = require("child_process");
|
|
|
7
7
|
const fs_1 = require("fs");
|
|
8
8
|
const os_1 = require("os");
|
|
9
9
|
const path_1 = require("path");
|
|
10
|
-
const sessions_1 = require("./sessions");
|
|
11
10
|
// ─── Bridge working directory ─────────────────────────────────────────────────
|
|
12
11
|
//
|
|
13
12
|
// Claude Code always loads CLAUDE.md files from cwd upward plus the global
|
|
@@ -52,7 +51,7 @@ function formatPayload(value) {
|
|
|
52
51
|
const active = new Map();
|
|
53
52
|
// ─── Spawn ────────────────────────────────────────────────────────────────────
|
|
54
53
|
function spawnClaude(params) {
|
|
55
|
-
const { conversationId, presenceSessionId, text, uid, vaultFileOpen, clientCapabilities, syncedFolders, onEvent, onDone, onError } = params;
|
|
54
|
+
const { conversationId, presenceSessionId, text, uid, history, vaultFileOpen, clientCapabilities, syncedFolders, onEvent, onDone, onError } = params;
|
|
56
55
|
const systemPromptPath = (0, path_1.join)((0, os_1.tmpdir)(), `agent-${uid}.md`);
|
|
57
56
|
const mcpConfigPath = (0, path_1.join)((0, os_1.tmpdir)(), `mcp-${uid}.json`);
|
|
58
57
|
if (verbose) {
|
|
@@ -60,6 +59,7 @@ function spawnClaude(params) {
|
|
|
60
59
|
process.stderr.write(`[bridge:verbose] override md: ${(0, path_1.join)(BRIDGE_CWD, 'CLAUDE.md')}\n`);
|
|
61
60
|
process.stderr.write(`[bridge:verbose] system prompt: ${systemPromptPath}\n`);
|
|
62
61
|
process.stderr.write(`[bridge:verbose] mcp config: ${mcpConfigPath}\n`);
|
|
62
|
+
process.stderr.write(`[bridge:verbose] history turns: ${history.length}\n`);
|
|
63
63
|
}
|
|
64
64
|
// If a prior process is still running for this conversation (user sent a
|
|
65
65
|
// follow-up before the previous turn finished), supersede it. The latest
|
|
@@ -70,7 +70,6 @@ function spawnClaude(params) {
|
|
|
70
70
|
existing.kill('SIGTERM');
|
|
71
71
|
active.delete(conversationId);
|
|
72
72
|
}
|
|
73
|
-
const claudeSessionId = presenceSessionId ? (0, sessions_1.getClaudeSession)(presenceSessionId) : undefined;
|
|
74
73
|
const ctxParts = [];
|
|
75
74
|
if (vaultFileOpen)
|
|
76
75
|
ctxParts.push(`vault_file_open: ${vaultFileOpen}`);
|
|
@@ -78,7 +77,7 @@ function spawnClaude(params) {
|
|
|
78
77
|
ctxParts.push(`client_capabilities: ${clientCapabilities.join(', ')}`);
|
|
79
78
|
if (syncedFolders?.length)
|
|
80
79
|
ctxParts.push(`synced_folders: ${syncedFolders.join(', ')}`);
|
|
81
|
-
const
|
|
80
|
+
const userMessageText = ctxParts.length > 0 ? `[${ctxParts.join(' | ')}]\n\n${text}` : text;
|
|
82
81
|
// Lockdown rationale:
|
|
83
82
|
// - `--tools ""` disables ALL built-in tools (Bash/Read/Write/Edit/Glob/Grep/
|
|
84
83
|
// WebFetch/etc.). MCP tools are not "built-in" so the 1Presence MCP surface
|
|
@@ -90,8 +89,19 @@ function spawnClaude(params) {
|
|
|
90
89
|
// - `--strict-mcp-config` keeps the MCP surface to exactly what we wire in
|
|
91
90
|
// via --mcp-config. Together these guarantee the bridge can only call
|
|
92
91
|
// `mcp__1presence__*` — no filesystem, no shell, no arbitrary network.
|
|
92
|
+
//
|
|
93
|
+
// Session continuity rationale:
|
|
94
|
+
// - `--input-format stream-json` accepts structured user/assistant messages
|
|
95
|
+
// on stdin. We replay prior turns (loaded by the gateway from Firestore)
|
|
96
|
+
// followed by the new user turn — this is how the bridge sees history.
|
|
97
|
+
// - `--no-session-persistence` keeps no jsonl on disk. The bridge has zero
|
|
98
|
+
// local filesystem dependency for continuity; Firestore is the only
|
|
99
|
+
// source of truth.
|
|
100
|
+
// - `--session-id <presenceSessionId>` pins the CLI session UUID to the
|
|
101
|
+
// presence sessionId so logs across bridge/gateway are easy to correlate.
|
|
93
102
|
const args = [
|
|
94
|
-
'
|
|
103
|
+
'--print',
|
|
104
|
+
'--input-format', 'stream-json',
|
|
95
105
|
'--output-format', 'stream-json',
|
|
96
106
|
'--verbose',
|
|
97
107
|
'--tools', '',
|
|
@@ -100,23 +110,46 @@ function spawnClaude(params) {
|
|
|
100
110
|
'--system-prompt-file', systemPromptPath,
|
|
101
111
|
'--mcp-config', mcpConfigPath,
|
|
102
112
|
'--strict-mcp-config',
|
|
113
|
+
'--no-session-persistence',
|
|
114
|
+
'--session-id', presenceSessionId,
|
|
103
115
|
];
|
|
104
116
|
const pinnedModel = (0, config_1.getBridgeModel)();
|
|
105
117
|
if (pinnedModel) {
|
|
106
118
|
args.push('--model', pinnedModel);
|
|
107
119
|
}
|
|
108
|
-
if (claudeSessionId) {
|
|
109
|
-
args.push('--resume', claudeSessionId);
|
|
110
|
-
}
|
|
111
120
|
// Strip API key so Claude Code uses the user's claude.ai Pro subscription
|
|
112
121
|
// (OAuth credentials), not an API key that would bill to a separate account.
|
|
113
122
|
const { ANTHROPIC_API_KEY: _stripped, ...safeEnv } = process.env;
|
|
114
123
|
const proc = (0, child_process_1.spawn)('claude', args, {
|
|
115
124
|
cwd: BRIDGE_CWD,
|
|
116
125
|
env: safeEnv,
|
|
117
|
-
stdio: ['
|
|
126
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
118
127
|
});
|
|
119
128
|
active.set(conversationId, proc);
|
|
129
|
+
// Feed prior turns + the new user message via stdin as stream-json.
|
|
130
|
+
// Each line is a JSON object: `{ type, message: { role, content } }`.
|
|
131
|
+
// Sanitisation (orphan tool_use stripping, displayOnly filtering) already
|
|
132
|
+
// happened on the gateway via @presence/shared.toModelMessages — replay
|
|
133
|
+
// the history verbatim and append the fresh user turn.
|
|
134
|
+
try {
|
|
135
|
+
const stdin = proc.stdin;
|
|
136
|
+
if (!stdin) {
|
|
137
|
+
throw new Error('claude stdin is null — spawn must use stdio[0]="pipe"');
|
|
138
|
+
}
|
|
139
|
+
for (const msg of history) {
|
|
140
|
+
const wrapped = { type: msg.role, message: { role: msg.role, content: msg.content } };
|
|
141
|
+
stdin.write(JSON.stringify(wrapped) + '\n');
|
|
142
|
+
}
|
|
143
|
+
const newTurn = { type: 'user', message: { role: 'user', content: userMessageText } };
|
|
144
|
+
stdin.write(JSON.stringify(newTurn) + '\n');
|
|
145
|
+
stdin.end();
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
process.stderr.write(`[bridge] failed to write stdin: ${err.message}\n`);
|
|
149
|
+
proc.kill('SIGTERM');
|
|
150
|
+
onError(`stdin write failed: ${err.message}`, null, null);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
120
153
|
let sessionIdExtracted = false;
|
|
121
154
|
let messageCount = 0;
|
|
122
155
|
let costUsd = 0;
|
|
@@ -140,12 +173,10 @@ function spawnClaude(params) {
|
|
|
140
173
|
continue;
|
|
141
174
|
}
|
|
142
175
|
const type = event['type'];
|
|
143
|
-
// Extract
|
|
176
|
+
// Extract model + key source info from the first system/init event.
|
|
177
|
+
// No session-id persistence — Firestore is the only source of truth
|
|
178
|
+
// now, and we pin --session-id to presenceSessionId on every spawn.
|
|
144
179
|
if (!sessionIdExtracted && type === 'system' && event['subtype'] === 'init') {
|
|
145
|
-
const sid = event['session_id'];
|
|
146
|
-
if (sid && presenceSessionId) {
|
|
147
|
-
(0, sessions_1.saveClaudeSession)(presenceSessionId, sid);
|
|
148
|
-
}
|
|
149
180
|
const keySource = event['apiKeySource'];
|
|
150
181
|
const model = event['model'];
|
|
151
182
|
if (model)
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,8 @@ const auth_1 = require("./auth");
|
|
|
12
12
|
const claude_1 = require("./claude");
|
|
13
13
|
const config_1 = require("./config");
|
|
14
14
|
const update_1 = require("./update");
|
|
15
|
+
const accumulator_1 = require("./accumulator");
|
|
16
|
+
const outbox_1 = require("./outbox");
|
|
15
17
|
const package_json_1 = require("../package.json");
|
|
16
18
|
// Published tarballs don't ship src/, so this fires only when running the
|
|
17
19
|
// dist build from a live workspace checkout. Catches the trap where editing
|
|
@@ -117,8 +119,13 @@ function writeRestricted(path, data) {
|
|
|
117
119
|
(0, fs_1.writeFileSync)(path, data, { mode: 0o600 });
|
|
118
120
|
(0, fs_1.chmodSync)(path, 0o600);
|
|
119
121
|
}
|
|
122
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
123
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
124
|
+
function isUuid(value) {
|
|
125
|
+
return UUID_RE.test(value);
|
|
126
|
+
}
|
|
120
127
|
// ─── Handle a single incoming message (token refresh + spawn) ─────────────────
|
|
121
|
-
async function handleMessage(conversationId, text, sessionId, auth, vaultFileOpen, clientCapabilities, syncedFolders) {
|
|
128
|
+
async function handleMessage(conversationId, text, sessionId, history, auth, vaultFileOpen, clientCapabilities, syncedFolders) {
|
|
122
129
|
// Refresh JWT if <10 min remaining before spawning Claude
|
|
123
130
|
let activeAuth = auth;
|
|
124
131
|
try {
|
|
@@ -161,15 +168,61 @@ async function handleMessage(conversationId, text, sessionId, auth, vaultFileOpe
|
|
|
161
168
|
return;
|
|
162
169
|
}
|
|
163
170
|
let responding = false;
|
|
171
|
+
const accumulator = (0, accumulator_1.makeBridgeAccumulator)();
|
|
172
|
+
const startedAt = Date.now();
|
|
173
|
+
const turnSessionId = sessionId ?? conversationId; // gateway always supplies one; defensive fallback
|
|
174
|
+
// --session-id requires a UUID; turnSessionId is always one in practice
|
|
175
|
+
// (gateway generates via crypto.randomUUID). Defensive: if a non-UUID slips
|
|
176
|
+
// through, fall back to conversationId which is also crypto.randomUUID.
|
|
177
|
+
const claudePinnedSessionId = isUuid(turnSessionId) ? turnSessionId : conversationId;
|
|
178
|
+
function buildSpoolRecord(usage, model) {
|
|
179
|
+
const s = accumulator.state();
|
|
180
|
+
return {
|
|
181
|
+
sessionId: turnSessionId,
|
|
182
|
+
conversationId,
|
|
183
|
+
userMessage: text,
|
|
184
|
+
assistantText: s.assistantText,
|
|
185
|
+
toolCalls: s.toolCalls,
|
|
186
|
+
toolResults: s.toolResults,
|
|
187
|
+
...(s.title ? { title: s.title } : {}),
|
|
188
|
+
usage: usage
|
|
189
|
+
? { ...usage, ...(model ? { model } : {}) }
|
|
190
|
+
: null,
|
|
191
|
+
startedAt,
|
|
192
|
+
finalizedAt: Date.now(),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
async function finalizeAndPost(record) {
|
|
196
|
+
// Spool BEFORE the network call so a crash between here and the POST
|
|
197
|
+
// ack is recoverable by drain-on-startup. The gateway dedupes on
|
|
198
|
+
// conversationId, so a replay is idempotent.
|
|
199
|
+
try {
|
|
200
|
+
(0, outbox_1.writeSpool)(record);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
console.warn(`[bridge] spool write failed: ${err.message}`);
|
|
204
|
+
}
|
|
205
|
+
const result = await (0, accumulator_1.postSaveTurn)(GATEWAY_HTTP, activeAuth.token, record);
|
|
206
|
+
if (result.ok) {
|
|
207
|
+
(0, outbox_1.deleteSpool)(record.conversationId);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
// Leave the spool file in place — next startup or next successful
|
|
211
|
+
// POST opportunity will retry. Quietly log so users aren't alarmed.
|
|
212
|
+
console.warn(`[bridge] save-turn POST failed (${result.status}): ${result.error ?? 'unknown'} — kept on disk for retry`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
164
215
|
(0, claude_1.spawnClaude)({
|
|
165
216
|
conversationId,
|
|
166
|
-
presenceSessionId:
|
|
217
|
+
presenceSessionId: claudePinnedSessionId,
|
|
167
218
|
text,
|
|
168
219
|
uid: activeAuth.uid,
|
|
220
|
+
history,
|
|
169
221
|
vaultFileOpen,
|
|
170
222
|
clientCapabilities,
|
|
171
223
|
syncedFolders,
|
|
172
224
|
onEvent: (event) => {
|
|
225
|
+
accumulator.consume(event);
|
|
173
226
|
if (!responding && event['type'] === 'assistant') {
|
|
174
227
|
responding = true;
|
|
175
228
|
console.log(`[${new Date().toLocaleTimeString()}] ◐ responding…`);
|
|
@@ -186,6 +239,7 @@ async function handleMessage(conversationId, text, sessionId, auth, vaultFileOpe
|
|
|
186
239
|
parts.push(costStr);
|
|
187
240
|
const suffix = parts.length ? ` ${parts.join(' ')}` : '';
|
|
188
241
|
console.log(`[${new Date().toLocaleTimeString()}] ✓ done${suffix}`);
|
|
242
|
+
const mapped = toBridgeUsage(usage);
|
|
189
243
|
if (currentWs?.readyState === ws_1.default.OPEN) {
|
|
190
244
|
currentWs.send(JSON.stringify({
|
|
191
245
|
type: 'done',
|
|
@@ -193,34 +247,60 @@ async function handleMessage(conversationId, text, sessionId, auth, vaultFileOpe
|
|
|
193
247
|
messageCount,
|
|
194
248
|
costUsd,
|
|
195
249
|
model,
|
|
196
|
-
usage:
|
|
197
|
-
inputTokens: usage.input_tokens,
|
|
198
|
-
outputTokens: usage.output_tokens,
|
|
199
|
-
cacheReadTokens: usage.cache_read_input_tokens,
|
|
200
|
-
cacheCreationTokens: usage.cache_creation_input_tokens,
|
|
201
|
-
} : null,
|
|
250
|
+
usage: mapped,
|
|
202
251
|
}));
|
|
203
252
|
}
|
|
253
|
+
// HTTP fallback runs unconditionally — gateway dedupes against WS path.
|
|
254
|
+
void finalizeAndPost(buildSpoolRecord(mapped, model));
|
|
204
255
|
},
|
|
205
256
|
onError: (message, usage, model) => {
|
|
206
257
|
console.error(`[${new Date().toLocaleTimeString()}] ✗ ${message}`);
|
|
258
|
+
const mapped = toBridgeUsage(usage);
|
|
207
259
|
if (currentWs?.readyState === ws_1.default.OPEN) {
|
|
208
260
|
currentWs.send(JSON.stringify({
|
|
209
261
|
type: 'error',
|
|
210
262
|
conversationId,
|
|
211
263
|
message,
|
|
212
264
|
model,
|
|
213
|
-
usage:
|
|
214
|
-
inputTokens: usage.input_tokens,
|
|
215
|
-
outputTokens: usage.output_tokens,
|
|
216
|
-
cacheReadTokens: usage.cache_read_input_tokens,
|
|
217
|
-
cacheCreationTokens: usage.cache_creation_input_tokens,
|
|
218
|
-
} : null,
|
|
265
|
+
usage: mapped,
|
|
219
266
|
}));
|
|
220
267
|
}
|
|
268
|
+
void finalizeAndPost(buildSpoolRecord(mapped, model));
|
|
221
269
|
},
|
|
222
270
|
});
|
|
223
271
|
}
|
|
272
|
+
// ─── Usage shape adapter ──────────────────────────────────────────────────────
|
|
273
|
+
function toBridgeUsage(usage) {
|
|
274
|
+
if (!usage)
|
|
275
|
+
return null;
|
|
276
|
+
return {
|
|
277
|
+
inputTokens: usage.input_tokens,
|
|
278
|
+
outputTokens: usage.output_tokens,
|
|
279
|
+
cacheReadTokens: usage.cache_read_input_tokens,
|
|
280
|
+
cacheCreationTokens: usage.cache_creation_input_tokens,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
// ─── Outbox drain ─────────────────────────────────────────────────────────────
|
|
284
|
+
//
|
|
285
|
+
// On bridge startup and on every successful reconnect, replay any spool
|
|
286
|
+
// records that didn't get a successful POST ack last time. The gateway
|
|
287
|
+
// dedupes on conversationId — if it already saved via the WS path, the
|
|
288
|
+
// reply is finalized=false and we still delete the spool.
|
|
289
|
+
async function drainOutbox(auth) {
|
|
290
|
+
const records = (0, outbox_1.listSpool)();
|
|
291
|
+
if (records.length === 0)
|
|
292
|
+
return;
|
|
293
|
+
console.log(`[bridge] draining ${records.length} pending save record${records.length === 1 ? '' : 's'}…`);
|
|
294
|
+
for (const record of records) {
|
|
295
|
+
const result = await (0, accumulator_1.postSaveTurn)(GATEWAY_HTTP, auth.token, record);
|
|
296
|
+
if (result.ok) {
|
|
297
|
+
(0, outbox_1.deleteSpool)(record.conversationId);
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
console.warn(`[bridge] drain POST failed (${result.status}): ${result.error ?? 'unknown'} — leaving on disk`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
224
304
|
// ─── WebSocket connection ─────────────────────────────────────────────────────
|
|
225
305
|
// Application-level heartbeat — avoids relying on WebSocket control frames (ping/pong),
|
|
226
306
|
// which some proxies (GKE LB) may not forward reliably.
|
|
@@ -260,6 +340,12 @@ function connect(auth, retryDelay = 1000) {
|
|
|
260
340
|
retryDelay = 1000;
|
|
261
341
|
console.log('✓ Bridge connected. Local Mode active on all your devices.\n');
|
|
262
342
|
startPing();
|
|
343
|
+
// Drain any save records left behind by an earlier crashed/dropped session.
|
|
344
|
+
// Fire-and-forget — the network is up, the gateway is reachable, and we
|
|
345
|
+
// don't want to block message handling on this.
|
|
346
|
+
if (currentAuth) {
|
|
347
|
+
drainOutbox(currentAuth).catch(err => console.warn(`[bridge] drain failed: ${err.message}`));
|
|
348
|
+
}
|
|
263
349
|
});
|
|
264
350
|
ws.on('message', (raw) => {
|
|
265
351
|
let msg;
|
|
@@ -281,11 +367,11 @@ function connect(auth, retryDelay = 1000) {
|
|
|
281
367
|
}
|
|
282
368
|
if (msg.type !== 'message' || !msg.conversationId || !msg.text)
|
|
283
369
|
return;
|
|
284
|
-
const { conversationId, text, sessionId, vaultFileOpen, clientCapabilities, syncedFolders } = msg;
|
|
370
|
+
const { conversationId, text, sessionId, history, vaultFileOpen, clientCapabilities, syncedFolders } = msg;
|
|
285
371
|
const ts = new Date().toLocaleTimeString();
|
|
286
|
-
const
|
|
287
|
-
console.log(`[${ts}] ▶ ${
|
|
288
|
-
handleMessage(conversationId, text, sessionId ?? null, auth, vaultFileOpen, clientCapabilities, syncedFolders).catch((err) => {
|
|
372
|
+
const hist = Array.isArray(history) ? history : [];
|
|
373
|
+
console.log(`[${ts}] ▶ ${text}${hist.length ? ` (history: ${hist.length} turn${hist.length === 1 ? '' : 's'})` : ''}`);
|
|
374
|
+
handleMessage(conversationId, text, sessionId ?? null, hist, auth, vaultFileOpen, clientCapabilities, syncedFolders).catch((err) => {
|
|
289
375
|
console.error(`[bridge] handleMessage error: ${err.message}`);
|
|
290
376
|
});
|
|
291
377
|
});
|
package/dist/outbox.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.writeSpool = writeSpool;
|
|
4
|
+
exports.deleteSpool = deleteSpool;
|
|
5
|
+
exports.listSpool = listSpool;
|
|
6
|
+
const fs_1 = require("fs");
|
|
7
|
+
const os_1 = require("os");
|
|
8
|
+
const path_1 = require("path");
|
|
9
|
+
// ─── On-disk turn spool ───────────────────────────────────────────────────────
|
|
10
|
+
//
|
|
11
|
+
// Each in-flight bridge turn writes a record to ~/.1presence/outbox/. The file
|
|
12
|
+
// exists from the moment the turn starts until the gateway acks the save-turn
|
|
13
|
+
// POST. If the bridge is killed (Ctrl+C, terminal closed, crash) between
|
|
14
|
+
// Claude finishing and the ack landing, the next startup drains the directory
|
|
15
|
+
// — covering the failure mode the WS+HTTP path alone can't.
|
|
16
|
+
//
|
|
17
|
+
// Records are keyed by conversationId, which the gateway also dedupes on, so
|
|
18
|
+
// a drained replay is idempotent: if it already saved via WS, the POST is a
|
|
19
|
+
// 200 no-op and the spool file is deleted.
|
|
20
|
+
//
|
|
21
|
+
// Payload mode 0600 — the file contains the user's assistant transcript and
|
|
22
|
+
// tool inputs. Tightened on every write to handle legacy world-readable files.
|
|
23
|
+
const OUTBOX_DIR = (0, path_1.join)((0, os_1.homedir)(), '.1presence', 'outbox');
|
|
24
|
+
function ensureDir() {
|
|
25
|
+
(0, fs_1.mkdirSync)(OUTBOX_DIR, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
function pathFor(conversationId) {
|
|
28
|
+
return (0, path_1.join)(OUTBOX_DIR, `${conversationId}.json`);
|
|
29
|
+
}
|
|
30
|
+
function writeSpool(record) {
|
|
31
|
+
ensureDir();
|
|
32
|
+
(0, fs_1.writeFileSync)(pathFor(record.conversationId), JSON.stringify(record), { mode: 0o600 });
|
|
33
|
+
}
|
|
34
|
+
function deleteSpool(conversationId) {
|
|
35
|
+
try {
|
|
36
|
+
(0, fs_1.unlinkSync)(pathFor(conversationId));
|
|
37
|
+
}
|
|
38
|
+
catch { /* already gone — fine */ }
|
|
39
|
+
}
|
|
40
|
+
function listSpool() {
|
|
41
|
+
ensureDir();
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const file of (0, fs_1.readdirSync)(OUTBOX_DIR)) {
|
|
44
|
+
if (!file.endsWith('.json'))
|
|
45
|
+
continue;
|
|
46
|
+
try {
|
|
47
|
+
const raw = (0, fs_1.readFileSync)((0, path_1.join)(OUTBOX_DIR, file), 'utf-8');
|
|
48
|
+
out.push(JSON.parse(raw));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Malformed file — leave it alone so a human can inspect.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
package/package.json
CHANGED
package/dist/sessions.js
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.getClaudeSession = getClaudeSession;
|
|
4
|
-
exports.saveClaudeSession = saveClaudeSession;
|
|
5
|
-
const fs_1 = require("fs");
|
|
6
|
-
const os_1 = require("os");
|
|
7
|
-
const path_1 = require("path");
|
|
8
|
-
// ─── Storage ──────────────────────────────────────────────────────────────────
|
|
9
|
-
const CONFIG_DIR = (0, path_1.join)((0, os_1.homedir)(), '.1presence');
|
|
10
|
-
const SESSIONS_FILE = (0, path_1.join)(CONFIG_DIR, 'sessions.json');
|
|
11
|
-
let cache = null;
|
|
12
|
-
function getMap() {
|
|
13
|
-
if (cache)
|
|
14
|
-
return cache;
|
|
15
|
-
try {
|
|
16
|
-
const raw = (0, fs_1.readFileSync)(SESSIONS_FILE, 'utf-8');
|
|
17
|
-
cache = JSON.parse(raw);
|
|
18
|
-
}
|
|
19
|
-
catch {
|
|
20
|
-
cache = {};
|
|
21
|
-
}
|
|
22
|
-
return cache;
|
|
23
|
-
}
|
|
24
|
-
function persist(map) {
|
|
25
|
-
(0, fs_1.mkdirSync)(CONFIG_DIR, { recursive: true });
|
|
26
|
-
(0, fs_1.writeFileSync)(SESSIONS_FILE, JSON.stringify(map, null, 2), 'utf-8');
|
|
27
|
-
}
|
|
28
|
-
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
29
|
-
function getClaudeSession(presenceSessionId) {
|
|
30
|
-
return getMap()[presenceSessionId];
|
|
31
|
-
}
|
|
32
|
-
function saveClaudeSession(presenceSessionId, claudeSessionId) {
|
|
33
|
-
const map = getMap();
|
|
34
|
-
map[presenceSessionId] = claudeSessionId;
|
|
35
|
-
persist(map);
|
|
36
|
-
}
|