@nocville/npc 0.4.0 → 0.5.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/README.md +29 -27
- package/dist/cjs/cli.js +32 -14
- package/dist/cjs/core/base-npc.js +9 -0
- package/dist/cjs/core/browser-connect.js +196 -0
- package/dist/cjs/core/pixel-art.js +4 -1
- package/dist/cjs/core/polling-npc.js +3 -2
- package/dist/cjs/npcs/agent-common/runtime.js +54 -22
- package/dist/cjs/npcs/agent-common/session.js +35 -9
- package/dist/cjs/npcs/agent-common/watchdog.js +4 -3
- package/dist/cjs/npcs/claude-code/defaults.js +2 -2
- package/dist/cjs/npcs/codex/defaults.js +2 -2
- package/dist/cjs/npcs/cursor/defaults.js +1 -1
- package/dist/cjs/npcs/cursor/hook-handler.js +1 -1
- package/dist/cjs/npcs/gemini/defaults.js +1 -1
- package/dist/cjs/npcs/web-page-watcher/pixelate.js +3 -2
- package/dist/cli.js +32 -14
- package/dist/core/base-npc.js +9 -0
- package/dist/core/browser-connect.d.ts +16 -0
- package/dist/core/browser-connect.js +189 -0
- package/dist/core/pixel-art.js +4 -1
- package/dist/core/polling-npc.d.ts +3 -2
- package/dist/core/polling-npc.js +3 -2
- package/dist/npcs/agent-common/runtime.js +54 -22
- package/dist/npcs/agent-common/session.js +35 -9
- package/dist/npcs/agent-common/watchdog.d.ts +2 -1
- package/dist/npcs/agent-common/watchdog.js +4 -3
- package/dist/npcs/claude-code/defaults.d.ts +1 -1
- package/dist/npcs/claude-code/defaults.js +2 -2
- package/dist/npcs/codex/defaults.d.ts +1 -1
- package/dist/npcs/codex/defaults.js +2 -2
- package/dist/npcs/cursor/defaults.js +1 -1
- package/dist/npcs/cursor/hook-handler.js +1 -1
- package/dist/npcs/gemini/defaults.js +1 -1
- package/dist/npcs/web-page-watcher/pixelate.js +3 -2
- package/package.json +2 -2
|
@@ -6,7 +6,7 @@ const node_path_1 = require("node:path");
|
|
|
6
6
|
exports.GEMINI_DEFAULTS = {
|
|
7
7
|
stateDir: (0, node_path_1.join)((0, node_os_1.tmpdir)(), 'nocville-gemini'),
|
|
8
8
|
stateFileMaxAge: 24 * 60 * 60 * 1000,
|
|
9
|
-
heartbeatSeconds:
|
|
9
|
+
heartbeatSeconds: 360,
|
|
10
10
|
minPushGapMs: 1500,
|
|
11
11
|
hookTimeoutMs: 5000,
|
|
12
12
|
};
|
|
@@ -7,6 +7,7 @@ exports.quantize = quantize;
|
|
|
7
7
|
exports.buildPixelImageScreen = buildPixelImageScreen;
|
|
8
8
|
exports.fnv1a = fnv1a;
|
|
9
9
|
exports.pngToPixelImage = pngToPixelImage;
|
|
10
|
+
const node_zlib_1 = require("node:zlib");
|
|
10
11
|
const pngjs_1 = require("pngjs");
|
|
11
12
|
/** Default target resolution for the web-view TV (4:3 to match a 4×3 companion). */
|
|
12
13
|
exports.TARGET_W = 160;
|
|
@@ -143,9 +144,9 @@ function buildPixelImageScreen(width, height, palette, indices) {
|
|
|
143
144
|
return {
|
|
144
145
|
layout: 'pixel-image',
|
|
145
146
|
values: [
|
|
146
|
-
{ label: 'fmt', value: `idx8;${width}x${height};p${paletteCount}` },
|
|
147
|
+
{ label: 'fmt', value: `idx8;${width}x${height};p${paletteCount};z` },
|
|
147
148
|
{ label: 'palette', value: Buffer.from(palette).toString('base64') },
|
|
148
|
-
{ label: 'pixels', value:
|
|
149
|
+
{ label: 'pixels', value: (0, node_zlib_1.deflateRawSync)(indices, { level: 9 }).toString('base64') },
|
|
149
150
|
],
|
|
150
151
|
};
|
|
151
152
|
}
|
package/dist/cli.js
CHANGED
|
@@ -12,9 +12,9 @@ const USAGE = `
|
|
|
12
12
|
nocville-npc <command> [options]
|
|
13
13
|
|
|
14
14
|
Quick start (AI coworkers):
|
|
15
|
-
nocville-npc setup
|
|
15
|
+
nocville-npc setup Connect your office in the browser,
|
|
16
16
|
then hook up installed coding agents
|
|
17
|
-
|
|
17
|
+
nocville-npc setup --reconnect Choose an office and renew this connection
|
|
18
18
|
nocville-npc setup --api-key=<agent key> Same, non-interactive
|
|
19
19
|
nocville-npc uninstall Remove all Nocville hooks
|
|
20
20
|
|
|
@@ -151,22 +151,27 @@ async function main() {
|
|
|
151
151
|
console.log(USAGE);
|
|
152
152
|
process.exit(1);
|
|
153
153
|
}
|
|
154
|
-
|
|
154
|
+
let config = resolveConfig(parsed.flags);
|
|
155
155
|
const logger = new Logger('cli', config.debug);
|
|
156
156
|
// Setup-style commands resolve the key: --api-key > env > saved > hidden prompt.
|
|
157
157
|
const isAgentSetup = ['claude-code', 'codex', 'gemini', 'cursor'].includes(parsed.command) &&
|
|
158
158
|
parsed.subcommand === 'setup' &&
|
|
159
159
|
!parsed.flags['uninstall'];
|
|
160
160
|
if (isAgentSetup || parsed.command === 'setup') {
|
|
161
|
-
if (
|
|
162
|
-
const {
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
161
|
+
if (!config.workspace || !config.apiKey.includes('.team_') || parsed.flags['reconnect']) {
|
|
162
|
+
const { connectCloudflareOffice } = await import('./core/browser-connect.js');
|
|
163
|
+
if (await connectCloudflareOffice(config.apiUrl)) {
|
|
164
|
+
const { loadUserConfig } = await import('./core/user-config.js');
|
|
165
|
+
const saved = loadUserConfig();
|
|
166
|
+
for (const [flag, value] of Object.entries({
|
|
167
|
+
'api-key': saved.apiKey,
|
|
168
|
+
'api-url': saved.apiUrl,
|
|
169
|
+
map: saved.map,
|
|
170
|
+
workspace: saved.workspace,
|
|
171
|
+
}))
|
|
172
|
+
if (value)
|
|
173
|
+
parsed.flags[flag] = value;
|
|
174
|
+
config = resolveConfig(parsed.flags);
|
|
170
175
|
}
|
|
171
176
|
}
|
|
172
177
|
const key = await resolveSetupKey(parsed.flags);
|
|
@@ -174,6 +179,16 @@ async function main() {
|
|
|
174
179
|
return;
|
|
175
180
|
if (key !== undefined)
|
|
176
181
|
parsed.flags['api-key'] = key;
|
|
182
|
+
config = resolveConfig(parsed.flags);
|
|
183
|
+
if (config.workspace) {
|
|
184
|
+
const { saveUserConfig } = await import('./core/user-config.js');
|
|
185
|
+
saveUserConfig({
|
|
186
|
+
workspace: config.workspace,
|
|
187
|
+
apiUrl: config.apiUrl,
|
|
188
|
+
apiKey: config.apiKey,
|
|
189
|
+
map: config.map,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
177
192
|
}
|
|
178
193
|
// AI-agent integrations have their own config handling (hooks read the saved config).
|
|
179
194
|
if (parsed.command === 'claude-code') {
|
|
@@ -256,6 +271,10 @@ async function main() {
|
|
|
256
271
|
return;
|
|
257
272
|
}
|
|
258
273
|
// All other NPC types require api-url and token
|
|
274
|
+
if (!config.workspace &&
|
|
275
|
+
(config.apiKey.includes('.team_') ||
|
|
276
|
+
config.apiUrl.replace(/\/+$/, '') === 'https://nocville.com'))
|
|
277
|
+
throw new Error('Connect this computer to your office first: run npx @nocville/npc@latest setup. Existing legacy keys cannot publish encrypted NPC updates.');
|
|
259
278
|
if (!config.apiKey) {
|
|
260
279
|
console.error('Error: no API key — run `nocville-npc init --api-key=<key>`, or pass --api-key / NOCVILLE_API_KEY');
|
|
261
280
|
process.exit(1);
|
|
@@ -604,8 +623,7 @@ async function runSetupAll(flags) {
|
|
|
604
623
|
console.log('· Cursor not found (~/.cursor missing) — skipped');
|
|
605
624
|
}
|
|
606
625
|
if (!any) {
|
|
607
|
-
console.
|
|
608
|
-
process.exitCode = 1;
|
|
626
|
+
console.log('No supported coding agents found. You can still run NPC commands on this computer.');
|
|
609
627
|
}
|
|
610
628
|
}
|
|
611
629
|
main().catch((err) => {
|
package/dist/core/base-npc.js
CHANGED
|
@@ -136,6 +136,15 @@ export class BaseNPC {
|
|
|
136
136
|
catch {
|
|
137
137
|
/* observer only */
|
|
138
138
|
}
|
|
139
|
+
if (!result.success && [401, 410].includes(result.statusCode ?? 0)) {
|
|
140
|
+
// A revoked key or an SDK version the server retired: retrying forever only
|
|
141
|
+
// costs requests. Stop; restarting after fixing it resumes monitoring.
|
|
142
|
+
this.logger.error(result.statusCode === 410
|
|
143
|
+
? 'This @nocville/npc version is no longer supported — upgrade it. Stopping.'
|
|
144
|
+
: 'API key rejected (HTTP 401) — check NOCVILLE_API_KEY / team settings. Stopping.');
|
|
145
|
+
void this.stop();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
139
148
|
if (!result.success) {
|
|
140
149
|
this.reportPushFailure(result.error);
|
|
141
150
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ProducerWorkspace } from '@nocville/node';
|
|
2
|
+
export interface BrowserConnection {
|
|
3
|
+
apiKey: string;
|
|
4
|
+
workspace: ProducerWorkspace;
|
|
5
|
+
}
|
|
6
|
+
export declare function validateConnection(input: unknown, origin: string, deploymentId: string): BrowserConnection;
|
|
7
|
+
/** A one-use loopback receiver. Credentials never appear in a URL or terminal output. */
|
|
8
|
+
export declare function browserConnection(origin: string, deploymentId: string, save: (value: BrowserConnection) => void | Promise<void>, timeoutMs?: number): Promise<{
|
|
9
|
+
url: string;
|
|
10
|
+
done: Promise<void>;
|
|
11
|
+
close: () => void;
|
|
12
|
+
}>;
|
|
13
|
+
export declare function saveBrowserConnection(value: BrowserConnection): void;
|
|
14
|
+
export declare function openConnectionBrowser(url: string): void;
|
|
15
|
+
export declare function connectCloudflareOffice(apiUrl: string): Promise<boolean>;
|
|
16
|
+
//# sourceMappingURL=browser-connect.d.ts.map
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { randomBytes, createHash } from 'node:crypto';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { loadProducerWorkspace } from '@nocville/node';
|
|
7
|
+
import { loadUserConfig, saveUserConfig, userConfigPath } from './user-config.js';
|
|
8
|
+
export function validateConnection(input, origin, deploymentId) {
|
|
9
|
+
const value = input;
|
|
10
|
+
const e = value?.workspace?.encryption;
|
|
11
|
+
if (typeof value?.apiKey !== 'string' ||
|
|
12
|
+
!/^nvk_[a-f0-9]{64}\.team_[A-Za-z0-9_-]{1,64}$/.test(value.apiKey) ||
|
|
13
|
+
value.workspace?.apiUrl !== origin ||
|
|
14
|
+
e?.deploymentId !== deploymentId ||
|
|
15
|
+
!/^team_[A-Za-z0-9_-]{1,64}$/.test(e?.teamId ?? '') ||
|
|
16
|
+
!/^map_[A-Za-z0-9_-]{1,128}$/.test(e?.mapId ?? '') ||
|
|
17
|
+
!/^[A-Za-z0-9_-]{43}$/.test(e?.trustedRoot ?? '') ||
|
|
18
|
+
!value.apiKey.endsWith('.' + e.teamId) ||
|
|
19
|
+
e.profileDirectory !== undefined)
|
|
20
|
+
throw new Error('Invalid office connection. Start setup again.');
|
|
21
|
+
return {
|
|
22
|
+
apiKey: value.apiKey,
|
|
23
|
+
workspace: {
|
|
24
|
+
apiUrl: origin,
|
|
25
|
+
encryption: {
|
|
26
|
+
deploymentId,
|
|
27
|
+
teamId: e.teamId,
|
|
28
|
+
mapId: e.mapId,
|
|
29
|
+
trustedRoot: e.trustedRoot,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** A one-use loopback receiver. Credentials never appear in a URL or terminal output. */
|
|
35
|
+
export async function browserConnection(origin, deploymentId, save, timeoutMs = 300000) {
|
|
36
|
+
const target = new URL(origin);
|
|
37
|
+
if (target.origin !== origin ||
|
|
38
|
+
target.username ||
|
|
39
|
+
target.password ||
|
|
40
|
+
(target.protocol !== 'https:' &&
|
|
41
|
+
!(target.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(target.hostname))))
|
|
42
|
+
throw new Error('Use an HTTPS office address.');
|
|
43
|
+
const state = randomBytes(32).toString('hex');
|
|
44
|
+
let finish, fail;
|
|
45
|
+
const done = new Promise((resolve, reject) => {
|
|
46
|
+
finish = resolve;
|
|
47
|
+
fail = reject;
|
|
48
|
+
});
|
|
49
|
+
// A timeout may happen while the browser launcher is still opening.
|
|
50
|
+
void done.catch(() => { });
|
|
51
|
+
let consumed = false, port = 0;
|
|
52
|
+
const server = createServer(async (request, response) => {
|
|
53
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
54
|
+
if (request.headers.host !== `127.0.0.1:${port}` ||
|
|
55
|
+
request.url !== '/complete' ||
|
|
56
|
+
request.headers.origin !== origin) {
|
|
57
|
+
response.writeHead(403).end();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
response.setHeader('Access-Control-Allow-Origin', origin);
|
|
61
|
+
response.setHeader('Vary', 'Origin');
|
|
62
|
+
if (request.method === 'OPTIONS') {
|
|
63
|
+
response.setHeader('Access-Control-Allow-Methods', 'POST');
|
|
64
|
+
response.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
65
|
+
response.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
66
|
+
response.writeHead(204).end();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (request.method !== 'POST' ||
|
|
70
|
+
request.headers['content-type']?.split(';')[0] !== 'application/json') {
|
|
71
|
+
response.writeHead(415).end();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
let size = 0;
|
|
76
|
+
const chunks = [];
|
|
77
|
+
for await (const chunk of request) {
|
|
78
|
+
size += chunk.length;
|
|
79
|
+
if (size > 16384) {
|
|
80
|
+
response.writeHead(413).end();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
chunks.push(Buffer.from(chunk));
|
|
84
|
+
}
|
|
85
|
+
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
86
|
+
if (body.state !== state || consumed) {
|
|
87
|
+
response.writeHead(403).end();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const value = validateConnection(body, origin, deploymentId);
|
|
91
|
+
consumed = true;
|
|
92
|
+
await save(value);
|
|
93
|
+
response.once('finish', finish);
|
|
94
|
+
response.writeHead(200, { 'Content-Type': 'application/json' }).end('{"connected":true}');
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
response.writeHead(400).end('Could not save this office connection. Start setup again.');
|
|
98
|
+
if (consumed)
|
|
99
|
+
fail(new Error('Could not save the office connection. Start setup again.'));
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
server.requestTimeout = 15000;
|
|
103
|
+
server.headersTimeout = 10000;
|
|
104
|
+
await new Promise((resolve, reject) => {
|
|
105
|
+
server.once('error', reject);
|
|
106
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
107
|
+
});
|
|
108
|
+
port = server.address().port;
|
|
109
|
+
const timer = setTimeout(() => fail(new Error('Office connection timed out. Run setup again.')), timeoutMs);
|
|
110
|
+
const url = new URL('/npcs/connect', origin);
|
|
111
|
+
url.searchParams.set('port', String(port));
|
|
112
|
+
url.searchParams.set('state', state);
|
|
113
|
+
return {
|
|
114
|
+
url: url.href,
|
|
115
|
+
done,
|
|
116
|
+
close: () => {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
server.close();
|
|
119
|
+
server.closeAllConnections();
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export function saveBrowserConnection(value) {
|
|
124
|
+
const saved = loadUserConfig();
|
|
125
|
+
if (saved.workspace) {
|
|
126
|
+
const old = loadProducerWorkspace(saved.workspace);
|
|
127
|
+
if (old.apiUrl === value.workspace.apiUrl &&
|
|
128
|
+
old.encryption.teamId === value.workspace.encryption.teamId &&
|
|
129
|
+
(old.encryption.deploymentId !== value.workspace.encryption.deploymentId ||
|
|
130
|
+
old.encryption.trustedRoot !== value.workspace.encryption.trustedRoot))
|
|
131
|
+
throw new Error('The office encryption identity changed. Reconnect only after checking with your team owner.');
|
|
132
|
+
}
|
|
133
|
+
const directory = join(dirname(userConfigPath()), 'workspaces');
|
|
134
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
135
|
+
const name = createHash('sha256').update(JSON.stringify(value.workspace)).digest('hex');
|
|
136
|
+
const workspace = join(directory, name + '.json');
|
|
137
|
+
const temporary = workspace + '.' + randomBytes(8).toString('hex') + '.tmp';
|
|
138
|
+
writeFileSync(temporary, JSON.stringify(value.workspace, null, 2) + '\n', {
|
|
139
|
+
mode: 0o600,
|
|
140
|
+
flag: 'wx',
|
|
141
|
+
});
|
|
142
|
+
renameSync(temporary, workspace);
|
|
143
|
+
saveUserConfig({
|
|
144
|
+
workspace,
|
|
145
|
+
apiUrl: value.workspace.apiUrl,
|
|
146
|
+
apiKey: value.apiKey,
|
|
147
|
+
map: value.workspace.encryption.mapId,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
export function openConnectionBrowser(url) {
|
|
151
|
+
const command = process.platform === 'darwin'
|
|
152
|
+
? 'open'
|
|
153
|
+
: process.platform === 'win32'
|
|
154
|
+
? 'rundll32.exe'
|
|
155
|
+
: 'xdg-open';
|
|
156
|
+
const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
|
|
157
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
158
|
+
child.on('error', () => { }); // The printed URL also supports systems without a browser launcher.
|
|
159
|
+
child.unref();
|
|
160
|
+
}
|
|
161
|
+
export async function connectCloudflareOffice(apiUrl) {
|
|
162
|
+
const origin = new URL(apiUrl).origin;
|
|
163
|
+
const response = await fetch(new URL('/.well-known/nocville.json', origin), {
|
|
164
|
+
redirect: 'error',
|
|
165
|
+
signal: AbortSignal.timeout(8000),
|
|
166
|
+
});
|
|
167
|
+
if (response.status === 404)
|
|
168
|
+
return false;
|
|
169
|
+
if (!response.ok)
|
|
170
|
+
throw new Error('The office is unavailable. Try setup again shortly.');
|
|
171
|
+
const info = (await response.json());
|
|
172
|
+
if (!info.encryptedIngress)
|
|
173
|
+
return false;
|
|
174
|
+
if (typeof info.deploymentId !== 'string' || !/^[A-Za-z0-9:_-]{1,128}$/.test(info.deploymentId))
|
|
175
|
+
throw new Error('Invalid office identity.');
|
|
176
|
+
const connection = await browserConnection(origin, info.deploymentId, saveBrowserConnection);
|
|
177
|
+
console.log('Connect this computer in your browser. Sign in, choose your office, and click Connect.');
|
|
178
|
+
console.log(connection.url);
|
|
179
|
+
openConnectionBrowser(connection.url);
|
|
180
|
+
try {
|
|
181
|
+
await connection.done;
|
|
182
|
+
console.log('✓ Connected. Private NPC updates will be encrypted on this computer.');
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
connection.close();
|
|
186
|
+
}
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
//# sourceMappingURL=browser-connect.js.map
|
package/dist/core/pixel-art.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { deflateRawSync } from 'node:zlib';
|
|
2
3
|
import { PNG } from 'pngjs';
|
|
3
4
|
/**
|
|
4
5
|
* Helpers for custom pixel-art companions (`screenType: 'pixel-device'`): your NPC's own
|
|
@@ -63,7 +64,9 @@ export function encodePixelArt(art) {
|
|
|
63
64
|
const fps = Math.min(12, Math.max(1, Math.round(art.fps ?? 4)));
|
|
64
65
|
tokens.push(`f${frames.length}`, `fps${fps}`);
|
|
65
66
|
}
|
|
66
|
-
|
|
67
|
+
// Deflated indices (`z`) are typically 15–25× smaller for pixel art.
|
|
68
|
+
tokens.push('z');
|
|
69
|
+
const encoded = deflateRawSync(pixels, { level: 9 }).toString('base64');
|
|
67
70
|
if (encoded.length > MAX_VALUE)
|
|
68
71
|
throw new Error('Pixel art is too large to send; use fewer frames or a smaller size.');
|
|
69
72
|
return {
|
|
@@ -6,8 +6,9 @@ export interface PollingNpcConfig extends NpcConfig {
|
|
|
6
6
|
onCollection?: (metrics: CollectedMetrics) => void;
|
|
7
7
|
}
|
|
8
8
|
/**
|
|
9
|
-
* Lower bound for any poll interval, so a typo can't
|
|
10
|
-
*
|
|
9
|
+
* Lower bound for any poll interval, so a typo can't hammer the watched source.
|
|
10
|
+
* The SDK sends changed status at most every 10 s and heartbeats every 30 s;
|
|
11
|
+
* the server limits producers per key, team and NPC and answers 429.
|
|
11
12
|
*/
|
|
12
13
|
export declare const MIN_POLL_INTERVAL_MS = 5000;
|
|
13
14
|
export declare abstract class PollingNPC extends BaseNPC {
|
package/dist/core/polling-npc.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { BaseNPC } from './base-npc.js';
|
|
2
2
|
/**
|
|
3
|
-
* Lower bound for any poll interval, so a typo can't
|
|
4
|
-
*
|
|
3
|
+
* Lower bound for any poll interval, so a typo can't hammer the watched source.
|
|
4
|
+
* The SDK sends changed status at most every 10 s and heartbeats every 30 s;
|
|
5
|
+
* the server limits producers per key, team and NPC and answers 429.
|
|
5
6
|
*/
|
|
6
7
|
export const MIN_POLL_INTERVAL_MS = 5000;
|
|
7
8
|
/** How long `stop()` waits for an in-flight collection before pushing offline anyway. */
|
|
@@ -66,24 +66,57 @@ export function ensureStableCliPath(cliPath, _force = true) {
|
|
|
66
66
|
const pngRoot = findPackageRoot(dirname(req.resolve('pngjs')), 'pngjs');
|
|
67
67
|
if (!pngRoot)
|
|
68
68
|
throw new Error('Cannot install hook runtime: pngjs is missing');
|
|
69
|
+
// Resolve the complete production dependency closure before copying. Encryption
|
|
70
|
+
// introduced nested SDK dependencies that the old two-package snapshot omitted.
|
|
71
|
+
const dependencies = new Map();
|
|
72
|
+
const collect = (root) => {
|
|
73
|
+
const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
74
|
+
const previous = dependencies.get(manifest.name);
|
|
75
|
+
if (previous) {
|
|
76
|
+
if (readVersion(previous) !== manifest.version)
|
|
77
|
+
throw new Error(`Cannot install hook runtime: conflicting versions of ${manifest.name}`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
dependencies.set(manifest.name, root);
|
|
81
|
+
const resolveDependency = createRequire(join(root, 'package.json'));
|
|
82
|
+
for (const name of Object.keys(manifest.dependencies ?? {}).sort()) {
|
|
83
|
+
const dependency = findPackageRoot(dirname(resolveDependency.resolve(name)), name);
|
|
84
|
+
if (!dependency)
|
|
85
|
+
throw new Error(`Cannot install hook runtime: ${name} is missing`);
|
|
86
|
+
collect(dependency);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
collect(sdkRoot);
|
|
90
|
+
collect(pngRoot);
|
|
91
|
+
const files = (root, prefix = '') => readdirSync(join(root, prefix), { withFileTypes: true })
|
|
92
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
93
|
+
.flatMap((entry) => {
|
|
94
|
+
if (entry.name === 'node_modules' ||
|
|
95
|
+
entry.name.startsWith('.') ||
|
|
96
|
+
entry.name.endsWith('.map'))
|
|
97
|
+
return [];
|
|
98
|
+
const relative = join(prefix, entry.name);
|
|
99
|
+
return statSync(join(root, relative)).isDirectory() ? files(root, relative) : [relative];
|
|
100
|
+
});
|
|
101
|
+
const dependencyFiles = [...dependencies].map(([name, root]) => ({
|
|
102
|
+
name,
|
|
103
|
+
root,
|
|
104
|
+
files: files(root),
|
|
105
|
+
}));
|
|
69
106
|
const version = readVersion(pkgRoot);
|
|
70
107
|
const hash = createHash('sha256');
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
108
|
+
hash.update(readFileSync(join(pkgRoot, 'package.json')));
|
|
109
|
+
for (const file of files(join(pkgRoot, 'dist'))) {
|
|
110
|
+
hash.update(file);
|
|
111
|
+
hash.update(readFileSync(join(pkgRoot, 'dist', file)));
|
|
112
|
+
}
|
|
113
|
+
for (const dependency of dependencyFiles) {
|
|
114
|
+
hash.update(dependency.name);
|
|
115
|
+
for (const file of dependency.files) {
|
|
116
|
+
hash.update(file);
|
|
117
|
+
hash.update(readFileSync(join(dependency.root, file)));
|
|
79
118
|
}
|
|
80
|
-
};
|
|
81
|
-
for (const root of [pkgRoot, sdkRoot]) {
|
|
82
|
-
hash.update(readFileSync(join(root, 'package.json')));
|
|
83
|
-
hashTree(join(root, 'dist'));
|
|
84
119
|
}
|
|
85
|
-
hash.update(readFileSync(join(pngRoot, 'package.json')));
|
|
86
|
-
hashTree(join(pngRoot, 'lib'));
|
|
87
120
|
// Immutable snapshots also support multiple development builds of one version.
|
|
88
121
|
// Never remove files a running watchdog or the current pointer may still use.
|
|
89
122
|
const dest = join(dirname(userConfigPath()), 'runtime', `${version}-${hash.digest('hex').slice(0, 16)}`);
|
|
@@ -94,14 +127,13 @@ export function ensureStableCliPath(cliPath, _force = true) {
|
|
|
94
127
|
mkdirSync(stage, { recursive: true, mode: 0o700 });
|
|
95
128
|
cpSync(join(pkgRoot, 'package.json'), join(stage, 'package.json'));
|
|
96
129
|
cpSync(join(pkgRoot, 'dist'), join(stage, 'dist'), { recursive: true });
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
cpSync(join(pngRoot, 'lib'), join(pngDest, 'lib'), { recursive: true });
|
|
130
|
+
for (const dependency of dependencyFiles) {
|
|
131
|
+
const target = join(stage, 'node_modules', dependency.name);
|
|
132
|
+
for (const file of dependency.files) {
|
|
133
|
+
mkdirSync(dirname(join(target, file)), { recursive: true });
|
|
134
|
+
cpSync(join(dependency.root, file), join(target, file), { dereference: true });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
105
137
|
try {
|
|
106
138
|
renameSync(stage, dest);
|
|
107
139
|
}
|
|
@@ -239,17 +239,43 @@ export function buildAgentPayload(adapter, state, config) {
|
|
|
239
239
|
map: config.map,
|
|
240
240
|
};
|
|
241
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* Clients reused within one process (a long-lived watchdog): a fresh client
|
|
244
|
+
* re-runs enrollment, manifest, version and session requests on every push.
|
|
245
|
+
* Any failure (e.g. a hook process took over the producer session) drops it.
|
|
246
|
+
*/
|
|
247
|
+
const clients = new Map();
|
|
242
248
|
async function defaultPush(config, serviceId, payload, timeoutMs) {
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
249
|
+
const key = JSON.stringify([
|
|
250
|
+
config.workspace,
|
|
251
|
+
config.apiUrl,
|
|
252
|
+
config.apiKey,
|
|
247
253
|
serviceId,
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
254
|
+
timeoutMs,
|
|
255
|
+
]);
|
|
256
|
+
let client = clients.get(key);
|
|
257
|
+
if (!client) {
|
|
258
|
+
client = new NocvilleClient({
|
|
259
|
+
workspace: config.workspace,
|
|
260
|
+
apiUrl: config.apiUrl,
|
|
261
|
+
apiKey: config.apiKey,
|
|
262
|
+
serviceId,
|
|
263
|
+
timeout: timeoutMs,
|
|
264
|
+
retries: 0,
|
|
265
|
+
debug: config.debug,
|
|
266
|
+
});
|
|
267
|
+
clients.set(key, client);
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
const result = await client.pushStatus(payload);
|
|
271
|
+
if (!result.success)
|
|
272
|
+
clients.delete(key);
|
|
273
|
+
return result;
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
clients.delete(key);
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
253
279
|
}
|
|
254
280
|
/**
|
|
255
281
|
* Push the session's current state if it has unpushed changes (or is offline),
|
|
@@ -2,7 +2,8 @@ import type { NpcConfig } from '../../core/types.js';
|
|
|
2
2
|
import { isAgentAlive } from './liveness.js';
|
|
3
3
|
import type { AgentAdapter, PushDeps } from './session.js';
|
|
4
4
|
export declare const WATCHDOG_TICK_MS = 15000;
|
|
5
|
-
|
|
5
|
+
/** Liveness only (status changes push at once); each heartbeat is a billed request. */
|
|
6
|
+
export declare const WATCHDOG_HEARTBEAT_MS = 300000;
|
|
6
7
|
export declare const WATCHDOG_OFFLINE_RETRY_MS: number;
|
|
7
8
|
export declare function ensureWatchdog(adapter: AgentAdapter, config: NpcConfig, sessionId: string, cliPath?: string, lockWaitMs?: number): Promise<void>;
|
|
8
9
|
export interface WatchdogDeps extends PushDeps {
|
|
@@ -2,7 +2,8 @@ import { isAgentAlive } from './liveness.js';
|
|
|
2
2
|
import { withStateLock } from './lock.js';
|
|
3
3
|
import { flushSession, readSessionState, sleep, spawnDetachedAgent, stateFilePath, writeSessionState, } from './session.js';
|
|
4
4
|
export const WATCHDOG_TICK_MS = 15_000;
|
|
5
|
-
|
|
5
|
+
/** Liveness only (status changes push at once); each heartbeat is a billed request. */
|
|
6
|
+
export const WATCHDOG_HEARTBEAT_MS = 300_000;
|
|
6
7
|
export const WATCHDOG_OFFLINE_RETRY_MS = 15 * 60_000;
|
|
7
8
|
export async function ensureWatchdog(adapter, config, sessionId, cliPath, lockWaitMs) {
|
|
8
9
|
if (adapter.processNames?.length === 0 ||
|
|
@@ -37,7 +38,7 @@ export async function runWatchdog(adapter, config, sessionId, deps = {}) {
|
|
|
37
38
|
const delivery = {
|
|
38
39
|
...deps,
|
|
39
40
|
onResult: (result) => {
|
|
40
|
-
if (!result.success && [400, 401, 403, 404].includes(result.statusCode ?? 0))
|
|
41
|
+
if (!result.success && [400, 401, 403, 404, 410].includes(result.statusCode ?? 0))
|
|
41
42
|
rejected = true;
|
|
42
43
|
deps.onResult?.(result);
|
|
43
44
|
},
|
|
@@ -118,7 +119,7 @@ export async function runWatchdog(adapter, config, sessionId, deps = {}) {
|
|
|
118
119
|
...deps,
|
|
119
120
|
force: true,
|
|
120
121
|
onResult: (result) => {
|
|
121
|
-
if (!result.success && [400, 401, 403, 404].includes(result.statusCode ?? 0))
|
|
122
|
+
if (!result.success && [400, 401, 403, 404, 410].includes(result.statusCode ?? 0))
|
|
122
123
|
heartbeatRejected = true;
|
|
123
124
|
deps.onResult?.(result);
|
|
124
125
|
},
|
|
@@ -3,7 +3,7 @@ export declare const DEFAULTS: {
|
|
|
3
3
|
stateFilePrefix: string;
|
|
4
4
|
stateFileMaxAge: number;
|
|
5
5
|
httpTimeout: number;
|
|
6
|
-
/** Watchdog-backed sessions become unknown after
|
|
6
|
+
/** Watchdog-backed sessions become unknown after twelve minutes without a heartbeat. */
|
|
7
7
|
heartbeatSeconds: number;
|
|
8
8
|
/** Number of recent activity lines kept in the desk-sign ticker. */
|
|
9
9
|
activityBufferSize: number;
|
|
@@ -5,8 +5,8 @@ export const DEFAULTS = {
|
|
|
5
5
|
stateFilePrefix: 'session-',
|
|
6
6
|
stateFileMaxAge: 24 * 60 * 60 * 1000, // 24 hours
|
|
7
7
|
httpTimeout: 4000,
|
|
8
|
-
/** Watchdog-backed sessions become unknown after
|
|
9
|
-
heartbeatSeconds:
|
|
8
|
+
/** Watchdog-backed sessions become unknown after twelve minutes without a heartbeat. */
|
|
9
|
+
heartbeatSeconds: 360,
|
|
10
10
|
/** Number of recent activity lines kept in the desk-sign ticker. */
|
|
11
11
|
activityBufferSize: 8,
|
|
12
12
|
/** Coalesce same-phase activity pushes closer together than this. */
|
|
@@ -2,7 +2,7 @@ export declare const CODEX_DEFAULTS: {
|
|
|
2
2
|
/** Hook-mode per-session state (shared with the watcher to avoid double pushes). */
|
|
3
3
|
stateDir: string;
|
|
4
4
|
stateFileMaxAge: number;
|
|
5
|
-
/** Watchdog-backed sessions become unknown after
|
|
5
|
+
/** Watchdog-backed sessions become unknown after twelve minutes without a heartbeat. */
|
|
6
6
|
heartbeatSeconds: number;
|
|
7
7
|
minPushGapMs: number;
|
|
8
8
|
hookTimeoutSeconds: number;
|
|
@@ -4,8 +4,8 @@ export const CODEX_DEFAULTS = {
|
|
|
4
4
|
/** Hook-mode per-session state (shared with the watcher to avoid double pushes). */
|
|
5
5
|
stateDir: join(tmpdir(), 'nocville-codex'),
|
|
6
6
|
stateFileMaxAge: 24 * 60 * 60 * 1000,
|
|
7
|
-
/** Watchdog-backed sessions become unknown after
|
|
8
|
-
heartbeatSeconds:
|
|
7
|
+
/** Watchdog-backed sessions become unknown after twelve minutes without a heartbeat. */
|
|
8
|
+
heartbeatSeconds: 360,
|
|
9
9
|
minPushGapMs: 1500,
|
|
10
10
|
hookTimeoutSeconds: 10,
|
|
11
11
|
/** Watcher: Codex session rollout logs. */
|