@galda/cli 0.10.16 → 0.10.19
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/CLAUDE.md +30 -1
- package/app/index.html +418 -53
- package/bin/manager-for-ai.mjs +18 -12
- package/engine/lib.mjs +536 -12
- package/engine/relay-client.mjs +101 -32
- package/engine/server.mjs +478 -20
- package/package.json +2 -2
package/engine/relay-client.mjs
CHANGED
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
//
|
|
9
9
|
// RELAY_URL=wss://relay.example/agent RELAY_AGENT_TOKEN=… node engine/relay-client.mjs
|
|
10
10
|
|
|
11
|
-
import { readFileSync, existsSync, watch } from 'node:fs';
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, watch, unlinkSync } from 'node:fs';
|
|
12
12
|
import { resolve, dirname, join } from 'node:path';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { licenseTokenIdentity, relayReconnectDecision } from './lib.mjs';
|
|
15
16
|
|
|
16
17
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
18
|
const DATA_DIR = process.env.MANAGER_HOME
|
|
@@ -39,6 +40,31 @@ let currentWs = null;
|
|
|
39
40
|
let reconnectTimer = null;
|
|
40
41
|
let lastToken = readToken();
|
|
41
42
|
|
|
43
|
+
// Stand-down state (see lib.mjs relayReconnectDecision). When another device
|
|
44
|
+
// running the SAME account takes the relay slot, the relay evicts us with 4000;
|
|
45
|
+
// we go stoodDown and remember whose slot we lost, so a later token re-mint of
|
|
46
|
+
// that same account does NOT make us re-dial and re-evict them (the infinite
|
|
47
|
+
// loop). currentIdentity = the account we last dialed with.
|
|
48
|
+
let relayState = { stoodDown: false, standingIdentity: null };
|
|
49
|
+
let currentIdentity = licenseTokenIdentity(readToken());
|
|
50
|
+
|
|
51
|
+
// Publish the relay connection state to a file the local server can read and
|
|
52
|
+
// surface at /api (P4's "in use on another device → switch here" banner). Kept
|
|
53
|
+
// as a tiny best-effort file because the server (:4400) and this relay-client
|
|
54
|
+
// are separate processes that only share DATA_DIR.
|
|
55
|
+
const STATUS_FILE = join(DATA_DIR, 'relay-status.json');
|
|
56
|
+
const TAKEOVER_FILE = join(DATA_DIR, 'relay-takeover.req');
|
|
57
|
+
function publishStatus(status) {
|
|
58
|
+
try {
|
|
59
|
+
writeFileSync(STATUS_FILE, JSON.stringify({
|
|
60
|
+
status, // 'online' | 'connecting' | 'standing-down' | 'idle'
|
|
61
|
+
identity: currentIdentity || null,
|
|
62
|
+
stoodDown: relayState.stoodDown,
|
|
63
|
+
at: Date.now(),
|
|
64
|
+
}));
|
|
65
|
+
} catch { /* best-effort */ }
|
|
66
|
+
}
|
|
67
|
+
|
|
42
68
|
function scheduleReconnect(retryMs) {
|
|
43
69
|
if (reconnectTimer) return; // exactly one pending reconnect — never stack timers
|
|
44
70
|
reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(Math.min(retryMs * 2, 15000)); }, retryMs);
|
|
@@ -52,15 +78,21 @@ function connect(retryMs = 1000) {
|
|
|
52
78
|
// license.token watcher below calls connect() the instant sign-in writes the
|
|
53
79
|
// token. A signed-in agent that merely drops still reconnects (token present).
|
|
54
80
|
if (!DEV_EMAIL && !readToken()) return;
|
|
81
|
+
// Stood down: another device holds this account's slot. Do NOT dial — that is
|
|
82
|
+
// the re-evict that starts the mutual-kick loop. Only an explicit takeover
|
|
83
|
+
// (writes relay-takeover.req) or a real account switch clears this.
|
|
84
|
+
if (relayState.stoodDown) return;
|
|
55
85
|
// Single-socket invariant: never open a second socket while one is CONNECTING
|
|
56
86
|
// (0) or OPEN (1). Two live sockets with the same identity make the relay evict
|
|
57
87
|
// each other (server.mjs "replaced by a newer connection") → an infinite flap.
|
|
58
88
|
if (currentWs && (currentWs.readyState === 0 || currentWs.readyState === 1)) return;
|
|
59
89
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
90
|
+
currentIdentity = DEV_EMAIL ? DEV_EMAIL.trim().toLowerCase() : licenseTokenIdentity(readToken());
|
|
60
91
|
const auth = DEV_EMAIL ? `devEmail=${encodeURIComponent(DEV_EMAIL)}` : `token=${encodeURIComponent(readToken())}`;
|
|
61
92
|
const ws = new WebSocket(`${RELAY}?${auth}`);
|
|
62
93
|
currentWs = ws;
|
|
63
|
-
|
|
94
|
+
publishStatus('connecting');
|
|
95
|
+
ws.onopen = () => { retryMs = 1000; publishStatus('online'); console.log(`[agent] connected to relay ${RELAY}`); };
|
|
64
96
|
ws.onmessage = async (ev) => {
|
|
65
97
|
let m; try { m = JSON.parse(ev.data); } catch { return; }
|
|
66
98
|
if (m.type === 'abort') { live.get(m.id)?.abort(); live.delete(m.id); return; }
|
|
@@ -102,45 +134,82 @@ function connect(retryMs = 1000) {
|
|
|
102
134
|
};
|
|
103
135
|
ws.onclose = (ev) => {
|
|
104
136
|
if (ws !== currentWs) return; // a superseded socket (we already moved on) — don't reconnect
|
|
137
|
+
currentWs = null;
|
|
105
138
|
// Close 4000 = the relay evicted us because a NEWER agent for the SAME account
|
|
106
|
-
// connected (server.mjs "replaced by a newer connection").
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
139
|
+
// connected (server.mjs "replaced by a newer connection"). Stand down STICKILY:
|
|
140
|
+
// remember whose slot we lost so a later re-mint of that account's token can't
|
|
141
|
+
// silently re-dial and re-evict them (the app.galda.app reload "無限ループ").
|
|
142
|
+
// A normal network drop reconnects; an explicit takeover / real account switch
|
|
143
|
+
// clears the stand-down. See lib.mjs relayReconnectDecision.
|
|
144
|
+
const d = relayReconnectDecision(relayState, ev && ev.code === 4000
|
|
145
|
+
? { type: 'evicted', identity: currentIdentity }
|
|
146
|
+
: { type: 'dropped' });
|
|
147
|
+
relayState = d.state;
|
|
148
|
+
if (d.reason === 'superseded') {
|
|
149
|
+
publishStatus('standing-down');
|
|
150
|
+
console.log('[agent] relay: this account is live on another device — standing down here. Use "switch to this device" to take over.');
|
|
114
151
|
return;
|
|
115
152
|
}
|
|
153
|
+
if (!d.dial) { publishStatus('standing-down'); return; }
|
|
154
|
+
publishStatus('connecting');
|
|
116
155
|
console.log(`[agent] relay connection lost — retrying in ${retryMs}ms`);
|
|
117
156
|
scheduleReconnect(retryMs);
|
|
118
157
|
};
|
|
119
158
|
ws.onerror = () => { /* onclose follows */ };
|
|
120
159
|
}
|
|
121
160
|
|
|
122
|
-
|
|
161
|
+
// Redial the relay once, immediately, after detaching the old socket (so its
|
|
162
|
+
// onclose is ignored: ws !== currentWs) — no racing reconnect chains.
|
|
163
|
+
function redialNow() {
|
|
164
|
+
const old = currentWs;
|
|
165
|
+
currentWs = null;
|
|
166
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
167
|
+
try { old?.close(); } catch { /* already gone */ }
|
|
168
|
+
connect(1000);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Explicit "switch to this device" (P4): the local server writes relay-takeover.req
|
|
172
|
+
// when the user claims this device from the "in use on another device" banner. We
|
|
173
|
+
// clear the stand-down and reclaim the slot on purpose.
|
|
174
|
+
function handleTakeover() {
|
|
175
|
+
try { unlinkSync(TAKEOVER_FILE); } catch { /* already consumed */ }
|
|
176
|
+
// Only meaningful while stood down — if we already hold (or are dialing) the
|
|
177
|
+
// slot there is nothing to reclaim. This also absorbs fs.watch double-firing
|
|
178
|
+
// the request file (macOS emits multiple events) into a single reconnect.
|
|
179
|
+
if (!relayState.stoodDown) return;
|
|
180
|
+
const d = relayReconnectDecision(relayState, { type: 'takeover' });
|
|
181
|
+
relayState = d.state;
|
|
182
|
+
if (d.dial) { console.log('[agent] taking over this account on this device (user requested)'); redialNow(); }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!process.env.RELAY_CLIENT_NO_START) {
|
|
186
|
+
connect();
|
|
123
187
|
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
188
|
+
// Watch DATA_DIR for two triggers (the file may not exist yet on first run;
|
|
189
|
+
// best-effort — if fs.watch is unavailable the onclose retry still re-reads the
|
|
190
|
+
// token on the next natural reconnect):
|
|
191
|
+
// 1) license.token appears/changes → sign-in or account switch. Route through
|
|
192
|
+
// the reducer: a re-mint of the account we already stood down from does NOT
|
|
193
|
+
// re-dial (kills the mutual-kick loop); a genuinely different account does.
|
|
194
|
+
// 2) relay-takeover.req → explicit user takeover.
|
|
195
|
+
try {
|
|
196
|
+
watch(DATA_DIR, (_ev, f) => {
|
|
197
|
+
if (f === 'relay-takeover.req' || (f == null && existsSync(TAKEOVER_FILE))) { handleTakeover(); return; }
|
|
198
|
+
if (f && f !== 'license.token') return;
|
|
199
|
+
const t = readToken();
|
|
200
|
+
if (!t || t === lastToken) return;
|
|
135
201
|
lastToken = t;
|
|
202
|
+
const d = relayReconnectDecision(relayState, { type: 'tokenChanged', identity: licenseTokenIdentity(t) });
|
|
203
|
+
relayState = d.state;
|
|
204
|
+
if (!d.dial) {
|
|
205
|
+
publishStatus('standing-down');
|
|
206
|
+
console.log('[agent] license refreshed for the account already live on another device — staying down (use "switch to this device" to take over)');
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
136
209
|
console.log('[agent] signed in — reconnecting to relay with your identity');
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
connect(1000);
|
|
144
|
-
}
|
|
145
|
-
});
|
|
146
|
-
} catch { /* fs.watch unavailable on this platform */ }
|
|
210
|
+
redialNow();
|
|
211
|
+
});
|
|
212
|
+
} catch { /* fs.watch unavailable on this platform */ }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export { redialNow, handleTakeover };
|