@logicseek/hello-cc 1.0.2 → 1.0.3
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/CHANGELOG.md +34 -0
- package/docs/dsh.md +3 -3
- package/docs/dsh.zh-CN.md +3 -3
- package/lib/cli/app.mjs +1 -1
- package/lib/cli/commands/hook.mjs +3 -1
- package/lib/cli/commands/lifecycle.mjs +14 -3
- package/lib/cli/commands/query.mjs +15 -10
- package/lib/cli/commands/run.mjs +7 -6
- package/lib/db/connection.mjs +82 -9
- package/lib/discover.mjs +4 -4
- package/lib/integrations/hooks.mjs +132 -40
- package/lib/integrations/peers/identity.mjs +42 -7
- package/lib/runtime/native/service.mjs +107 -24
- package/lib/runtime/native/store.mjs +63 -6
- package/lib/runtime/project-path.mjs +90 -7
- package/lib/shared/file-lock.mjs +62 -17
- package/lib/shared/socket-lock-worker.mjs +15 -6
- package/lib/web/codex-app-server.mjs +19 -1
- package/lib/web/codex-sessions.mjs +2 -0
- package/lib/web/cookie-auth.mjs +1 -1
- package/lib/web/http-routes.mjs +11 -7
- package/lib/web/http.mjs +20 -2
- package/lib/web/native-sessions.mjs +13 -6
- package/lib/web/peer-actions.mjs +27 -27
- package/lib/web/runtime-main.mjs +20 -3
- package/lib/web/runtime.mjs +18 -4
- package/lib/web/session-serialize.mjs +1 -1
- package/lib/web/startup.mjs +84 -10
- package/lib/web/task-results.mjs +8 -5
- package/lib/web/terminal-assets.mjs +18 -0
- package/lib/web/tmux-stream.mjs +3 -4
- package/lib/web/ui-codex.mjs +137 -20
- package/lib/web/ui-interactions.mjs +37 -3
- package/lib/web/ui-native.mjs +21 -9
- package/lib/web/ui-template.mjs +8 -2
- package/package.json +1 -1
- package/scripts/regression.mjs +8 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
4
|
import { CliError } from '../shared/errors.mjs';
|
|
4
5
|
import { redactSecrets } from '../shared/redact.mjs';
|
|
5
6
|
import { tx } from '../db/schema.mjs';
|
|
@@ -29,10 +30,17 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
|
|
|
29
30
|
return redactSecrets({ code: error.code || 'NATIVE_RUNTIME_OFFLINE',
|
|
30
31
|
message: error.message || 'Native runtime is disconnected' });
|
|
31
32
|
}
|
|
33
|
+
function publishState(session, state) {
|
|
34
|
+
// Event cursors do not cover approvals, delivery receipts or capabilities.
|
|
35
|
+
// Compare the complete normalized state; validation and lease revocation
|
|
36
|
+
// still run on every read, including reads that need no new UI frame.
|
|
37
|
+
const changed = !isDeepStrictEqual(session.nativeState, state);
|
|
38
|
+
session.nativeState = state;
|
|
39
|
+
if (changed) broadcast(session, { type: 'native_state', state: session.nativeSnapshot() });
|
|
40
|
+
}
|
|
32
41
|
function disconnect(session, error, retire = false) {
|
|
33
42
|
session.status = 'disconnected';
|
|
34
|
-
session
|
|
35
|
-
broadcast(session, { type: 'native_state', state: session.nativeSnapshot() });
|
|
43
|
+
publishState(session, { ...session.nativeState, connected: false, status: 'disconnected', error: errorInfo(error) });
|
|
36
44
|
if (retire && !session.nativeRetired) {
|
|
37
45
|
session.nativeRetired = true;
|
|
38
46
|
closeSessionClients(session);
|
|
@@ -100,7 +108,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
|
|
|
100
108
|
const turnId = value.snapshot.turnId || value.snapshot.activeTurnId || value.active_delivery?.turn_id || null;
|
|
101
109
|
session.binding = binding;
|
|
102
110
|
session.cwd = value.cwd;
|
|
103
|
-
|
|
111
|
+
const state = redactSecrets({ backend: 'native', connected: true, root: value.root,
|
|
104
112
|
peer: value.peer, provider: value.provider, generation: value.generation, owner: value.owner,
|
|
105
113
|
executorId: value.owner, sessionId: value.snapshot.sessionId || null, turnId,
|
|
106
114
|
status: value.snapshot.status, cwd: value.cwd, capabilities: value.snapshot.capabilities || {}, pendingApprovals: value.snapshot.pendingApprovals || [],
|
|
@@ -111,7 +119,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
|
|
|
111
119
|
session.status = CLOSED.has(value.snapshot.status) ? 'exited'
|
|
112
120
|
: value.snapshot.status === 'disconnected' ? 'disconnected' : 'running';
|
|
113
121
|
if (session.status === 'exited') session.exitedAt = now();
|
|
114
|
-
|
|
122
|
+
publishState(session, state);
|
|
115
123
|
return session.nativeSnapshot();
|
|
116
124
|
}
|
|
117
125
|
function makeSession(ctx, checked) {
|
|
@@ -261,8 +269,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
|
|
|
261
269
|
if (action === 'respond') { await readState(session); current(session); }
|
|
262
270
|
if (action === 'close') {
|
|
263
271
|
session.status = 'exited'; session.exitedAt = now();
|
|
264
|
-
session
|
|
265
|
-
broadcast(session, { type: 'native_state', state: session.nativeSnapshot() });
|
|
272
|
+
publishState(session, { ...session.nativeState, connected: false, status: 'closed', closing: false });
|
|
266
273
|
closeSessionClients(session);
|
|
267
274
|
session.actionTokens.clear();
|
|
268
275
|
}
|
package/lib/web/peer-actions.mjs
CHANGED
|
@@ -3,10 +3,12 @@ import process from 'node:process';
|
|
|
3
3
|
import { intOpt, positiveSafeIntOpt, required } from '../cli-args.mjs';
|
|
4
4
|
import { CliError } from '../shared/errors.mjs';
|
|
5
5
|
import {
|
|
6
|
+
findRequestedLock,
|
|
6
7
|
lockLabel,
|
|
7
8
|
lockScope,
|
|
8
9
|
locksConflict,
|
|
9
|
-
scopedLockResource
|
|
10
|
+
scopedLockResource,
|
|
11
|
+
upsertRequestedLock
|
|
10
12
|
} from '../core/coordination/locks.mjs';
|
|
11
13
|
import { normalizeStateResources } from '../ui/state-render.mjs';
|
|
12
14
|
import {
|
|
@@ -103,23 +105,31 @@ export function createWebPeerActions(deps = {}) {
|
|
|
103
105
|
function webPeerRegister(projectCtx, peer, input = {}) {
|
|
104
106
|
webMutationPeer(peer, input);
|
|
105
107
|
const db = connect(projectCtx);
|
|
106
|
-
const row = {
|
|
107
|
-
id: peer,
|
|
108
|
-
kind: input.kind || 'other',
|
|
109
|
-
role: input.role || 'peer',
|
|
110
|
-
worktree: path.resolve(input.worktree || projectCtx.cwd),
|
|
111
|
-
branch: input.branch || detectBranch(projectCtx.cwd),
|
|
112
|
-
pid: intOpt(input, 'pid', process.ppid),
|
|
113
|
-
status: input.status || 'idle',
|
|
114
|
-
capabilities: Array.isArray(input.cap) ? input.cap.join(',') : (input.cap || input.capabilities || 'web')
|
|
115
|
-
};
|
|
116
108
|
try {
|
|
109
|
+
const existing = db.prepare('SELECT * FROM peers WHERE id = ?').get(peer) || null;
|
|
110
|
+
const explicitPid = Object.hasOwn(input, 'pid');
|
|
111
|
+
const row = {
|
|
112
|
+
id: peer,
|
|
113
|
+
kind: input.kind || existing?.kind || 'other',
|
|
114
|
+
role: input.role || existing?.role || 'peer',
|
|
115
|
+
worktree: path.resolve(input.worktree || existing?.worktree || projectCtx.cwd),
|
|
116
|
+
branch: input.branch || existing?.branch || detectBranch(projectCtx.cwd),
|
|
117
|
+
pid: explicitPid ? intOpt(input, 'pid') : (existing?.pid ?? null),
|
|
118
|
+
...(!explicitPid ? { processIdentity: existing ? {
|
|
119
|
+
pid: existing.pid,
|
|
120
|
+
startToken: existing.pid_start_token,
|
|
121
|
+
commandHash: existing.pid_command_hash
|
|
122
|
+
} : null } : {}),
|
|
123
|
+
status: input.status || existing?.status || 'idle',
|
|
124
|
+
capabilities: Array.isArray(input.cap) ? input.cap.join(',') :
|
|
125
|
+
(input.cap || input.capabilities || existing?.capabilities || 'web')
|
|
126
|
+
};
|
|
117
127
|
upsertPeer(db, row);
|
|
118
128
|
addEvent(db, 'peer.registered', peer, null, webAuditPayload(peer, input, row));
|
|
129
|
+
return { peer: row, summary: `registered ${row.id} (${row.kind}${row.role ? `, ${row.role}` : ''})` };
|
|
119
130
|
} finally {
|
|
120
131
|
db.close();
|
|
121
132
|
}
|
|
122
|
-
return { peer: row, summary: `registered ${row.id} (${row.kind}${row.role ? `, ${row.role}` : ''})` };
|
|
123
133
|
}
|
|
124
134
|
|
|
125
135
|
function webPeerHeartbeat(projectCtx, peer, input = {}) {
|
|
@@ -294,21 +304,11 @@ export function createWebPeerActions(deps = {}) {
|
|
|
294
304
|
}
|
|
295
305
|
const existing = subject.locks.find((row) => row.resource === requested.resource) || null;
|
|
296
306
|
const expiresAt = leaseDeadline(t, ttl);
|
|
297
|
-
db
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
ON CONFLICT(resource) DO UPDATE SET
|
|
301
|
-
base_resource = excluded.base_resource,
|
|
302
|
-
scope = excluded.scope,
|
|
303
|
-
owner = excluded.owner,
|
|
304
|
-
task_id = excluded.task_id,
|
|
305
|
-
reason = excluded.reason,
|
|
306
|
-
expires_at = excluded.expires_at,
|
|
307
|
-
created_at = excluded.created_at,
|
|
308
|
-
ttl_sec = excluded.ttl_sec
|
|
309
|
-
`).run(requested.resource, requested.base_resource, requested.scope, peer, taskId, reason, expiresAt, t, ttl);
|
|
307
|
+
const acquired = upsertRequestedLock(db, requested, {
|
|
308
|
+
owner: peer, task_id: taskId, reason, expires_at: expiresAt, created_at: t, ttl_sec: ttl
|
|
309
|
+
});
|
|
310
310
|
addEvent(db, 'lock.acquired', peer, taskId, webAuditPayload(peer, input, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, ttl, previous_owner: existing ? existing.owner : null }));
|
|
311
|
-
return { lock:
|
|
311
|
+
return { lock: acquired };
|
|
312
312
|
}
|
|
313
313
|
});
|
|
314
314
|
if (outcome.error) throw outcome.error;
|
|
@@ -327,7 +327,7 @@ export function createWebPeerActions(deps = {}) {
|
|
|
327
327
|
try {
|
|
328
328
|
touchPeer(db, peer, null);
|
|
329
329
|
const result = tx(db, () => {
|
|
330
|
-
const existing = db
|
|
330
|
+
const existing = findRequestedLock(db, requested);
|
|
331
331
|
if (!existing) return { released: false, ...requested };
|
|
332
332
|
if (existing.owner !== peer) {
|
|
333
333
|
throw new CliError('LOCK_OWNED', `Lock is owned by ${existing.owner}`, { owner: existing.owner });
|
package/lib/web/runtime-main.mjs
CHANGED
|
@@ -97,7 +97,8 @@ import {
|
|
|
97
97
|
requestOriginMatches,
|
|
98
98
|
sendFile,
|
|
99
99
|
sendJson,
|
|
100
|
-
tokenMatches
|
|
100
|
+
tokenMatches,
|
|
101
|
+
tokenlessRequestAllowed
|
|
101
102
|
} from '../web/http.mjs';
|
|
102
103
|
import {
|
|
103
104
|
API_VERSION,
|
|
@@ -488,14 +489,30 @@ const {
|
|
|
488
489
|
ACTIVE_PEER_TTL, DETECTED_PEER_MAX_AGE, PRODUCT_NAME, VERSION
|
|
489
490
|
});
|
|
490
491
|
|
|
492
|
+
const dispatchHttp = (req, res) => {
|
|
493
|
+
void handleWebRequest(req, res).catch((error) => {
|
|
494
|
+
try {
|
|
495
|
+
console.error(redactedLogText(`HTTP request failed: ${error.code || 'UNKNOWN'}`));
|
|
496
|
+
if (!res.headersSent) sendJson(res, 500, { ok: false, error: { code: 'SERVER_ERROR', message: 'internal server error' } });
|
|
497
|
+
else res.destroy();
|
|
498
|
+
} catch {
|
|
499
|
+
try { res.destroy(); } catch {}
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
};
|
|
491
503
|
const server = useTls
|
|
492
|
-
? https.createServer({ key: tlsCredentials.key, cert: tlsCredentials.cert },
|
|
493
|
-
: http.createServer(
|
|
504
|
+
? https.createServer({ key: tlsCredentials.key, cert: tlsCredentials.cert }, dispatchHttp)
|
|
505
|
+
: http.createServer(dispatchHttp);
|
|
494
506
|
|
|
495
507
|
const wss = new WebSocketServer({ noServer: true });
|
|
496
508
|
server.on('upgrade', (req, socket, head) => {
|
|
497
509
|
try {
|
|
498
510
|
const url = requestUrl(req);
|
|
511
|
+
if (!token && !tokenlessRequestAllowed(req, { trustProxy, proxyOrigin })) {
|
|
512
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
513
|
+
socket.destroy();
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
499
516
|
if (!readWebSocketApiVersion(url).ok) {
|
|
500
517
|
const body = JSON.stringify(apiVersionUnsupportedBody());
|
|
501
518
|
socket.end([
|
package/lib/web/runtime.mjs
CHANGED
|
@@ -14,8 +14,12 @@ export function runtimeConnectHost(host) {
|
|
|
14
14
|
return host;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
function urlHost(host) {
|
|
18
|
+
return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
19
|
+
}
|
|
20
|
+
|
|
17
21
|
export function runtimeBaseUrl(host, port, tls = false) {
|
|
18
|
-
return `${tls ? 'https' : 'http'}://${runtimeConnectHost(host)}:${port}`;
|
|
22
|
+
return `${tls ? 'https' : 'http'}://${urlHost(runtimeConnectHost(host))}:${port}`;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
export function runtimeApiUrl(runtime, route) {
|
|
@@ -24,7 +28,17 @@ export function runtimeApiUrl(runtime, route) {
|
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
export function requestUrl(req) {
|
|
27
|
-
|
|
31
|
+
try {
|
|
32
|
+
const base = new URL(`http://${req.headers.host || 'localhost'}`);
|
|
33
|
+
if (base.username || base.password || base.pathname !== '/' || base.search || base.hash) {
|
|
34
|
+
throw new Error('Invalid Host authority');
|
|
35
|
+
}
|
|
36
|
+
const url = new URL(req.url || '/', base);
|
|
37
|
+
if (url.origin !== base.origin) throw new Error('Request target changes authority');
|
|
38
|
+
return url;
|
|
39
|
+
} catch {
|
|
40
|
+
throw new CliError('BAD_REQUEST', 'Invalid request URL or Host header');
|
|
41
|
+
}
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
export function isLoopbackHost(host) {
|
|
@@ -84,13 +98,13 @@ export function publicRuntimeUrl(runtime, projectRoot = null) {
|
|
|
84
98
|
? '<machine-ip>'
|
|
85
99
|
: runtime.host || runtimeConnectHost(runtime.host || '127.0.0.1');
|
|
86
100
|
const scheme = runtime.tls ? 'https' : 'http';
|
|
87
|
-
return `${scheme}://${host}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
|
|
101
|
+
return `${scheme}://${urlHost(host)}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
|
|
88
102
|
}
|
|
89
103
|
|
|
90
104
|
export function localRuntimeUrl(runtime, projectRoot = null) {
|
|
91
105
|
const host = runtimeConnectHost(runtime.host || '127.0.0.1');
|
|
92
106
|
const scheme = runtime.tls ? 'https' : 'http';
|
|
93
|
-
return `${scheme}://${host}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
|
|
107
|
+
return `${scheme}://${urlHost(host)}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
|
|
94
108
|
}
|
|
95
109
|
|
|
96
110
|
// Issue a request to a runtime API route. Returns { ok, status, text }.
|
|
@@ -105,7 +105,7 @@ export function createSessionSerialize({
|
|
|
105
105
|
: session.type === 'native' ? ['chat', 'delivery-receipts', 'results',
|
|
106
106
|
...(session.nativeState?.capabilities?.interrupt ? ['turn-control'] : [])] : ['terminal', 'results'],
|
|
107
107
|
local_clients: session.type === 'tmux' ? localClientObservation(session) : null,
|
|
108
|
-
executor_id: session.adapter?.snapshot().executorId || session.nativeState?.executorId || null,
|
|
108
|
+
executor_id: session.executorId || session.adapter?.snapshot().executorId || session.nativeState?.executorId || null,
|
|
109
109
|
native_connected: session.type === 'native' ? Boolean(session.nativeState?.connected) : null,
|
|
110
110
|
created_at: session.createdAt,
|
|
111
111
|
exited_at: session.exitedAt || null,
|
package/lib/web/startup.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import process from 'node:process';
|
|
8
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
8
9
|
import { URL } from 'node:url';
|
|
9
10
|
import { spawn, spawnSync } from 'node:child_process';
|
|
10
11
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
@@ -13,10 +14,13 @@ import { redactCliArgs } from '../shared/redact.mjs';
|
|
|
13
14
|
import { intOpt, parseOpts, splitGlobalArgs, validateOpts } from '../cli-args.mjs';
|
|
14
15
|
import { printResult } from '../format.mjs';
|
|
15
16
|
import { commandPath, tailFile } from '../cli-runtime.mjs';
|
|
17
|
+
import { compareProcessIdentity, inspectProcessIdentity } from '../process/identity.mjs';
|
|
16
18
|
import { registerProject } from '../runtime/projects.mjs';
|
|
17
|
-
import { runtimePath, globalRuntimePath, webLogPath } from '../runtime/paths.mjs';
|
|
19
|
+
import { runtimePath, globalRuntimePath, globalStateDir, webLogPath } from '../runtime/paths.mjs';
|
|
18
20
|
import { readHealthyGlobalRuntime, writeRuntime } from '../runtime/state.mjs';
|
|
19
21
|
import { runtimeRequest } from '../runtime/client.mjs';
|
|
22
|
+
import { withFileLock } from '../shared/file-lock.mjs';
|
|
23
|
+
import { writeJsonSafe } from '../shared/json-file.mjs';
|
|
20
24
|
import {
|
|
21
25
|
expectedWebHost,
|
|
22
26
|
isLoopbackHost,
|
|
@@ -32,6 +36,56 @@ import { API_VERSION } from '../web/api-version.mjs';
|
|
|
32
36
|
import { ensureTmuxAvailable } from '../tmux.mjs';
|
|
33
37
|
|
|
34
38
|
export const WEB_RUNTIME_START_TIMEOUT_MS = 60_000;
|
|
39
|
+
const WEB_START_LOCK_WAIT_MS = WEB_RUNTIME_START_TIMEOUT_MS + 30_000;
|
|
40
|
+
|
|
41
|
+
function webStartOwnerAlive(owner) {
|
|
42
|
+
const pid = owner?.pid;
|
|
43
|
+
if (!Number.isInteger(pid) || pid <= 0) return true;
|
|
44
|
+
const observed = inspectProcessIdentity(pid);
|
|
45
|
+
if (observed.state === 'dead') return false;
|
|
46
|
+
if (owner.process_identity && observed.state === 'live') {
|
|
47
|
+
return compareProcessIdentity(owner.process_identity, observed.identity) !== 'dead';
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function withWebStartLock(fn) {
|
|
53
|
+
const lockFile = path.join(globalStateDir(), 'web-start.lock');
|
|
54
|
+
const owner = {
|
|
55
|
+
pid: process.pid,
|
|
56
|
+
process_identity: inspectProcessIdentity(process.pid).identity,
|
|
57
|
+
nonce: randomUUID()
|
|
58
|
+
};
|
|
59
|
+
const deadline = performance.now() + WEB_START_LOCK_WAIT_MS;
|
|
60
|
+
while (true) {
|
|
61
|
+
const acquired = withFileLock(lockFile, (lockedFile) => {
|
|
62
|
+
if (fs.existsSync(lockedFile)) {
|
|
63
|
+
let previous = null;
|
|
64
|
+
try { previous = JSON.parse(fs.readFileSync(lockedFile, 'utf8')); } catch {}
|
|
65
|
+
if (!previous || webStartOwnerAlive(previous)) return false;
|
|
66
|
+
}
|
|
67
|
+
writeJsonSafe(lockedFile, owner, { mode: 0o600 });
|
|
68
|
+
return true;
|
|
69
|
+
});
|
|
70
|
+
if (acquired) break;
|
|
71
|
+
if (performance.now() >= deadline) {
|
|
72
|
+
throw new CliError('WEB_START_LOCK_TIMEOUT',
|
|
73
|
+
'Timed out waiting for another web startup to finish.');
|
|
74
|
+
}
|
|
75
|
+
await sleep(100);
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
return await fn();
|
|
79
|
+
} finally {
|
|
80
|
+
withFileLock(lockFile, (lockedFile) => {
|
|
81
|
+
let current = null;
|
|
82
|
+
try { current = JSON.parse(fs.readFileSync(lockedFile, 'utf8')); } catch {}
|
|
83
|
+
if (current?.nonce === owner.nonce && current.pid === owner.pid) {
|
|
84
|
+
fs.rmSync(lockedFile, { force: true });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
35
89
|
|
|
36
90
|
export function createWebStartup(deps) {
|
|
37
91
|
const {
|
|
@@ -84,25 +138,41 @@ async function stopOrphanWebRuntimes(ctx, keepPid = null) {
|
|
|
84
138
|
const ppid = Number.parseInt(match[2], 10);
|
|
85
139
|
if (!Number.isFinite(pid)) continue;
|
|
86
140
|
if (Number.isFinite(ppid)) ppidByPid.set(pid, ppid);
|
|
87
|
-
rows.push({ pid, args: match[3] });
|
|
141
|
+
rows.push({ pid, ppid, args: match[3] });
|
|
88
142
|
}
|
|
89
143
|
|
|
90
144
|
const ancestorPids = currentProcessAncestorPids(ppidByPid);
|
|
91
|
-
const
|
|
145
|
+
const candidates = [];
|
|
92
146
|
for (const row of rows) {
|
|
147
|
+
// A live parent still owns its web command. In particular, another CLI
|
|
148
|
+
// invocation waiting for the startup lock is not an orphan runtime.
|
|
149
|
+
if (row.ppid !== 1) continue;
|
|
93
150
|
if (row.pid === process.pid || row.pid === keepPid || ancestorPids.has(row.pid)) continue;
|
|
94
|
-
if (hccWebProcessMatches(row.args, ctx))
|
|
151
|
+
if (!hccWebProcessMatches(row.args, ctx)) continue;
|
|
152
|
+
const observed = inspectProcessIdentity(row.pid);
|
|
153
|
+
if (observed.state === 'live' &&
|
|
154
|
+
observed.identity.commandHash === createHash('sha256').update(row.args.trim()).digest('hex')) {
|
|
155
|
+
candidates.push(observed.identity);
|
|
156
|
+
}
|
|
95
157
|
}
|
|
96
|
-
if (!
|
|
158
|
+
if (!candidates.length) return;
|
|
159
|
+
|
|
160
|
+
const stillSameProcess = (identity) => {
|
|
161
|
+
const observed = inspectProcessIdentity(identity.pid);
|
|
162
|
+
return observed.state === 'live' &&
|
|
163
|
+
compareProcessIdentity(identity, observed.identity) === 'live' &&
|
|
164
|
+
identity.commandHash === observed.identity.commandHash;
|
|
165
|
+
};
|
|
97
166
|
|
|
98
|
-
for (const
|
|
99
|
-
|
|
167
|
+
for (const identity of candidates) {
|
|
168
|
+
if (!stillSameProcess(identity)) continue;
|
|
169
|
+
try { process.kill(identity.pid, 'SIGTERM'); } catch {}
|
|
100
170
|
}
|
|
101
171
|
await sleep(250);
|
|
102
|
-
for (const
|
|
172
|
+
for (const identity of candidates) {
|
|
173
|
+
if (!stillSameProcess(identity)) continue;
|
|
103
174
|
try {
|
|
104
|
-
process.kill(pid,
|
|
105
|
-
process.kill(pid, 'SIGKILL');
|
|
175
|
+
process.kill(identity.pid, 'SIGKILL');
|
|
106
176
|
} catch {}
|
|
107
177
|
}
|
|
108
178
|
}
|
|
@@ -160,6 +230,10 @@ async function startWebBackground(ctx, args) {
|
|
|
160
230
|
assertWebTokenForHost(requestedHost, !opts['no-token']);
|
|
161
231
|
if (!isLoopbackHost(requestedHost)) console.error(redactedLogText(webExposureWarning(requestedHost, intOpt(opts, 'port', 8787)) + (opts.tls ? '' : ' Consider --tls to encrypt this connection.')));
|
|
162
232
|
ensureTmuxAvailable({ autoInstall: true });
|
|
233
|
+
return withWebStartLock(() => startWebBackgroundLocked(ctx, args, opts));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function startWebBackgroundLocked(ctx, args, opts) {
|
|
163
237
|
const setup = await prepareLocalBus(ctx, {
|
|
164
238
|
...opts,
|
|
165
239
|
installShims: process.env.HCC_SKIP_SHIM_INSTALL === '1' ? false : true
|
package/lib/web/task-results.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { selectCurrentTask } from '../core/coordination/automation.mjs';
|
|
3
3
|
import { listTaskResults, recordTaskResult, summarizeTaskResults } from '../core/coordination/results.mjs';
|
|
4
4
|
import { CliError } from '../shared/errors.mjs';
|
|
5
|
+
import { redactSecrets } from '../shared/redact.mjs';
|
|
5
6
|
|
|
6
7
|
export function createTaskResults({ connectWebProject, resolveSessionPeerId, now, addEvent }) {
|
|
7
8
|
const turnScopes = new WeakMap();
|
|
@@ -31,7 +32,7 @@ export function createTaskResults({ connectWebProject, resolveSessionPeerId, now
|
|
|
31
32
|
} finally { db.close(); }
|
|
32
33
|
}
|
|
33
34
|
function observeCodexResults(session, state) {
|
|
34
|
-
if (!session.binding?.provider_session_id || state.executorId !== session.adapter?.snapshot().executorId) return;
|
|
35
|
+
if (!session.binding?.provider_session_id || state.executorId !== (session.executorId || session.adapter?.snapshot().executorId)) return;
|
|
35
36
|
const thread = state.threads?.find(t => t.id === session.binding.provider_session_id);
|
|
36
37
|
if (!thread?.turns?.length) return;
|
|
37
38
|
const db = connectWebProject(session.ctx);
|
|
@@ -54,14 +55,16 @@ export function createTaskResults({ connectWebProject, resolveSessionPeerId, now
|
|
|
54
55
|
const exitCode = item.exitCode;
|
|
55
56
|
const status = exitCode === 0 && item.status === 'completed' ? 'passed'
|
|
56
57
|
: item.status === 'failed' || item.status === 'declined' || (Number.isSafeInteger(exitCode) && exitCode !== 0) ? 'failed' : 'pending';
|
|
58
|
+
const command = redactSecrets(String(item.command || ''));
|
|
59
|
+
const output = redactSecrets(String(item.aggregatedOutput || ''));
|
|
57
60
|
recordTaskResult(db, { ...common, itemId: item.id, kind: 'command', status, exitCode,
|
|
58
61
|
resultKey: `${state.executorId}:${turn.id}:${item.id}:command`,
|
|
59
|
-
title:
|
|
60
|
-
command:
|
|
61
|
-
details:
|
|
62
|
+
title: (command || 'Command execution').slice(0, 300),
|
|
63
|
+
command: command.slice(0, 64000),
|
|
64
|
+
details: output.slice(0, 64000), evidence: [] }, { now, addEvent });
|
|
62
65
|
}
|
|
63
66
|
if (turn.diff && ['completed', 'failed', 'interrupted'].includes(turn.status)) {
|
|
64
|
-
const diff = String(turn.diff).slice(0, 64000);
|
|
67
|
+
const diff = redactSecrets(String(turn.diff)).slice(0, 64000);
|
|
65
68
|
recordTaskResult(db, { ...common, kind: 'diff', status: 'pending', title: 'Turn changes awaiting review', diff,
|
|
66
69
|
resultKey: `${state.executorId}:${turn.id}:diff:${createHash('sha256').update(diff).digest('hex')}` }, { now, addEvent });
|
|
67
70
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
const packages = Object.freeze({
|
|
6
|
+
'xterm.js': '@xterm/xterm',
|
|
7
|
+
'xterm.css': '@xterm/xterm',
|
|
8
|
+
'addon-fit.js': '@xterm/addon-fit',
|
|
9
|
+
'addon-search.js': '@xterm/addon-search'
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// npm may place dependencies above the HCC package directory. Resolve from
|
|
13
|
+
// this module's installation rather than assuming a nested node_modules.
|
|
14
|
+
export function terminalAssetPath(name) {
|
|
15
|
+
if (!Object.hasOwn(packages, name)) throw new TypeError('Unknown terminal asset');
|
|
16
|
+
const entry = require.resolve(packages[name]);
|
|
17
|
+
return name === 'xterm.css' ? path.resolve(path.dirname(entry), '..', 'css', name) : entry;
|
|
18
|
+
}
|
package/lib/web/tmux-stream.mjs
CHANGED
|
@@ -7,12 +7,12 @@ import path from 'node:path';
|
|
|
7
7
|
import { spawnSync } from 'node:child_process';
|
|
8
8
|
import { runTmux, tmuxCapturePane, tmuxCursorInfo, tmuxCursorPayload } from '../terminal/tmux.mjs';
|
|
9
9
|
import { CliError } from '../shared/errors.mjs';
|
|
10
|
+
import { ensurePrivateProjectBufferDirectory } from '../runtime/project-path.mjs';
|
|
10
11
|
|
|
11
12
|
export function createTmuxStream({
|
|
12
13
|
broadcast,
|
|
13
14
|
now,
|
|
14
15
|
refreshPeerIoHeartbeat,
|
|
15
|
-
bufferDirectory,
|
|
16
16
|
withBufferDirectoryLease,
|
|
17
17
|
shellQuoteArg,
|
|
18
18
|
ctx
|
|
@@ -69,8 +69,7 @@ export function createTmuxStream({
|
|
|
69
69
|
function startTmuxStream(session) {
|
|
70
70
|
const safePane = String(session.pane).replace(/[^A-Za-z0-9_-]/g, '');
|
|
71
71
|
const safeId = String(session.id).replace(/[^A-Za-z0-9_.-]/g, '_');
|
|
72
|
-
const streamDirectory =
|
|
73
|
-
fs.mkdirSync(streamDirectory, { recursive: true });
|
|
72
|
+
const streamDirectory = ensurePrivateProjectBufferDirectory((session.ctx || ctx).root);
|
|
74
73
|
const pipeFile = path.join(streamDirectory, `tmux-${safePane}-${safeId}.pipe`);
|
|
75
74
|
session.pipeFile = pipeFile;
|
|
76
75
|
try { runTmux(['pipe-pane', '-t', session.pane]); } catch {}
|
|
@@ -89,7 +88,7 @@ export function createTmuxStream({
|
|
|
89
88
|
const message = (mkfifo.stderr || mkfifo.stdout || '').trim() || 'mkfifo failed';
|
|
90
89
|
throw new CliError('TMUX_STREAM_ERROR', message);
|
|
91
90
|
}
|
|
92
|
-
|
|
91
|
+
fs.chmodSync(pipeFile, 0o600);
|
|
93
92
|
session.streamFd = fs.openSync(pipeFile, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
|
|
94
93
|
});
|
|
95
94
|
} catch (err) {
|