@logicseek/hello-cc 1.1.0-rc.4 → 1.1.0-rc.6
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 +62 -0
- package/docs/README.md +2 -0
- package/docs/README.zh-CN.md +2 -0
- package/docs/app-bridge.md +110 -0
- package/docs/app-bridge.zh-CN.md +80 -0
- package/docs/dsh.md +1 -1
- package/docs/dsh.zh-CN.md +1 -1
- package/lib/cli/app.mjs +3 -0
- package/lib/cli/commands/app-bridge.mjs +89 -0
- package/lib/cli/commands/hook.mjs +21 -2
- package/lib/coordination-state.mjs +5 -2
- package/lib/core/coordination/messages.mjs +7 -2
- package/lib/integrations/claude-app-collaboration.mjs +222 -0
- package/lib/integrations/claude-app.mjs +289 -0
- package/lib/integrations/claude-mod/register.mjs +141 -0
- package/lib/integrations/codex-app-probe.mjs +213 -0
- package/lib/integrations/dsh-collaboration.mjs +19 -2
- package/lib/integrations/dsh-cordis.d.ts +2 -0
- package/lib/integrations/dsh-cordis.mjs +26 -33
- package/lib/integrations/dsh-inbox.mjs +251 -0
- package/lib/ui/help.mjs +1 -0
- package/package.json +1 -1
- package/scripts/dsh-inbox-acceptance.mjs +209 -0
- package/scripts/helpers/sqlite-observer.mjs +9 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import WebSocket from 'ws';
|
|
5
|
+
import { CliError } from '../shared/errors.mjs';
|
|
6
|
+
|
|
7
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
8
|
+
const text = (value, limit) => typeof value === 'string' && value.length > 0 && value.length <= limit && !/[\x00-\x1f\x7f]/.test(value);
|
|
9
|
+
const statuses = new Set(['notLoaded', 'idle', 'active', 'systemError']);
|
|
10
|
+
const methods = new Set(['initialize', 'thread/read', 'thread/loaded/list']);
|
|
11
|
+
const messages = Object.freeze({
|
|
12
|
+
DESKTOP_ENDPOINT_UNVERIFIED: 'The endpoint has not been verified as the server used by Codex Desktop.',
|
|
13
|
+
READ_ONLY_PROBE: 'This probe does not attach, resume, subscribe to, or send input to a thread.',
|
|
14
|
+
ENDPOINT_UNAVAILABLE: 'The explicitly selected Unix socket is unavailable.',
|
|
15
|
+
ENDPOINT_CHANGED: 'The selected Unix socket changed during connection setup.',
|
|
16
|
+
TRANSPORT_FAILED: 'The endpoint did not establish or retain the required WebSocket connection.',
|
|
17
|
+
PROBE_TIMEOUT: 'The bounded probe deadline expired.',
|
|
18
|
+
PROTOCOL_INVALID: 'The endpoint returned an invalid or unsupported protocol response.',
|
|
19
|
+
RESPONSE_LIMIT: 'The endpoint exceeded a probe response limit.',
|
|
20
|
+
METHOD_UNSUPPORTED: 'The endpoint does not support a required read-only method.',
|
|
21
|
+
REQUEST_REJECTED: 'The endpoint rejected a read-only probe request.',
|
|
22
|
+
THREAD_ID_MISMATCH: 'The returned thread identity did not match the requested thread.',
|
|
23
|
+
CWD_MISMATCH: 'The reported thread working directory did not match the expected absolute path.',
|
|
24
|
+
THREAD_NOT_LOADED: 'The target thread was not confirmed as loaded in this endpoint.',
|
|
25
|
+
THREAD_STATE_CHANGED: 'Thread metadata and the loaded-thread list did not agree.',
|
|
26
|
+
LOADED_LIST_INCOMPLETE: 'The loaded-thread list could not be completed within the page limit.',
|
|
27
|
+
SERVER_REQUEST_REJECTED: 'The endpoint requested an operation from the probe; the probe rejected it.',
|
|
28
|
+
DIRECT_INPUT_UNAVAILABLE: 'The endpoint did not advertise direct-input support for the target thread.'
|
|
29
|
+
});
|
|
30
|
+
const failure = code => Object.assign(new Error(code), { code });
|
|
31
|
+
|
|
32
|
+
function validate(options) {
|
|
33
|
+
if (!object(options)) throw new CliError('BAD_ARGS', 'Codex probing requires explicit endpoint, thread, and working-directory options.');
|
|
34
|
+
for (const key of ['socketPath', 'expectedCwd']) {
|
|
35
|
+
if (!text(options[key], 4096) || !path.isAbsolute(options[key])) {
|
|
36
|
+
throw new CliError('BAD_ARGS', `${key} must be an explicit absolute path.`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!text(options.threadId, 512)) throw new CliError('BAD_ARGS', 'threadId must be a nonempty bounded identifier.');
|
|
40
|
+
const limits = { timeoutMs: [5000, 10, 30000], pageSize: [100, 1, 100], maxPages: [10, 1, 100], maxPayloadBytes: [262144, 1024, 1048576] };
|
|
41
|
+
const result = { socketPath: options.socketPath, expectedCwd: options.expectedCwd, threadId: options.threadId };
|
|
42
|
+
for (const [key, [defaultValue, minimum, maximum]] of Object.entries(limits)) {
|
|
43
|
+
const value = options[key] ?? defaultValue;
|
|
44
|
+
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
45
|
+
throw new CliError('BAD_ARGS', `${key} must be an integer between ${minimum} and ${maximum}.`);
|
|
46
|
+
}
|
|
47
|
+
result[key] = value;
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function socketIdentity(socketPath) {
|
|
53
|
+
try {
|
|
54
|
+
const stat = fs.lstatSync(socketPath);
|
|
55
|
+
if (!stat.isSocket()) throw failure('ENDPOINT_UNAVAILABLE');
|
|
56
|
+
return { dev: stat.dev, ino: stat.ino };
|
|
57
|
+
} catch { throw failure('ENDPOINT_UNAVAILABLE'); }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function connectProbe(options, identity, report, addCondition) {
|
|
61
|
+
let ws, timer, pending, openResolve, openReject, failed, disposed = false;
|
|
62
|
+
let nextId = 1, frameCount = 0, totalBytes = 0;
|
|
63
|
+
const byteLimit = Math.min(options.maxPayloadBytes * (options.maxPages + 4), 16 * 1024 * 1024);
|
|
64
|
+
const opened = new Promise((resolve, reject) => { openResolve = resolve; openReject = reject; });
|
|
65
|
+
function fail(code) {
|
|
66
|
+
if (failed || disposed) return;
|
|
67
|
+
failed = failure(code);
|
|
68
|
+
openReject(failed);
|
|
69
|
+
pending?.reject(failed);
|
|
70
|
+
pending = null;
|
|
71
|
+
}
|
|
72
|
+
function write(frame) {
|
|
73
|
+
if (failed) throw failed;
|
|
74
|
+
if (disposed || ws.readyState !== WebSocket.OPEN) throw failure('TRANSPORT_FAILED');
|
|
75
|
+
ws.send(JSON.stringify(frame), error => { if (error) fail('TRANSPORT_FAILED'); });
|
|
76
|
+
}
|
|
77
|
+
function onMessage(bytes, binary) {
|
|
78
|
+
if (failed || disposed) return;
|
|
79
|
+
totalBytes += bytes.length;
|
|
80
|
+
if (++frameCount > 1024 || totalBytes > byteLimit) return fail('RESPONSE_LIMIT');
|
|
81
|
+
if (binary) return fail('PROTOCOL_INVALID');
|
|
82
|
+
let frame;
|
|
83
|
+
try { frame = JSON.parse(bytes.toString('utf8')); } catch { return fail('PROTOCOL_INVALID'); }
|
|
84
|
+
if (!object(frame) || (frame.jsonrpc !== undefined && frame.jsonrpc !== '2.0')) return fail('PROTOCOL_INVALID');
|
|
85
|
+
if (Object.hasOwn(frame, 'method')) {
|
|
86
|
+
if (!text(frame.method, 256)) return fail('PROTOCOL_INVALID');
|
|
87
|
+
if (Object.hasOwn(frame, 'id')) {
|
|
88
|
+
if (!(Number.isSafeInteger(frame.id) || text(frame.id, 512))) return fail('PROTOCOL_INVALID');
|
|
89
|
+
addCondition('SERVER_REQUEST_REJECTED');
|
|
90
|
+
try { write({ id: frame.id, error: { code: -32601, message: 'Read-only probe does not execute server requests.' } }); }
|
|
91
|
+
catch { fail('TRANSPORT_FAILED'); }
|
|
92
|
+
}
|
|
93
|
+
return; // Notifications and their contents are never retained.
|
|
94
|
+
}
|
|
95
|
+
if (!pending || frame.id !== pending.id || Object.hasOwn(frame, 'result') === Object.hasOwn(frame, 'error')) return fail('PROTOCOL_INVALID');
|
|
96
|
+
const operation = pending;
|
|
97
|
+
pending = null;
|
|
98
|
+
if (Object.hasOwn(frame, 'error')) {
|
|
99
|
+
operation.reject(failure(object(frame.error) && frame.error.code === -32601 ? 'METHOD_UNSUPPORTED' : 'REQUEST_REJECTED'));
|
|
100
|
+
} else operation.resolve(frame.result);
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
// The public UDS transport uses a WebSocket handshake, not stdio JSONL.
|
|
104
|
+
// No URL, ambient proxy, cookie, auth token, or default socket is accepted.
|
|
105
|
+
ws = new WebSocket('ws://localhost/', {
|
|
106
|
+
createConnection: () => net.createConnection({ path: options.socketPath }),
|
|
107
|
+
followRedirects: false, perMessageDeflate: false,
|
|
108
|
+
maxPayload: options.maxPayloadBytes, handshakeTimeout: options.timeoutMs
|
|
109
|
+
});
|
|
110
|
+
timer = setTimeout(() => fail('PROBE_TIMEOUT'), options.timeoutMs);
|
|
111
|
+
ws.on('open', () => {
|
|
112
|
+
if (failed || disposed) return;
|
|
113
|
+
try {
|
|
114
|
+
const current = socketIdentity(options.socketPath);
|
|
115
|
+
if (identity.dev !== current.dev || identity.ino !== current.ino) throw failure('ENDPOINT_CHANGED');
|
|
116
|
+
} catch { fail('ENDPOINT_CHANGED'); return; }
|
|
117
|
+
report.transportConnected = true;
|
|
118
|
+
openResolve();
|
|
119
|
+
});
|
|
120
|
+
ws.on('message', onMessage);
|
|
121
|
+
ws.on('error', error => fail(error.code === 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH' ? 'RESPONSE_LIMIT' : 'TRANSPORT_FAILED'));
|
|
122
|
+
ws.on('close', () => fail('TRANSPORT_FAILED'));
|
|
123
|
+
} catch { fail('TRANSPORT_FAILED'); }
|
|
124
|
+
return {
|
|
125
|
+
opened,
|
|
126
|
+
request(method, params) {
|
|
127
|
+
if (!methods.has(method) || pending) return Promise.reject(failure('PROTOCOL_INVALID'));
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
const id = nextId++;
|
|
130
|
+
pending = { id, resolve, reject };
|
|
131
|
+
try { write({ id, method, params }); }
|
|
132
|
+
catch (error) { pending = null; reject(error); }
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
initialized() { write({ method: 'initialized', params: {} }); },
|
|
136
|
+
async close() {
|
|
137
|
+
disposed = true;
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
if (!ws || ws.readyState === WebSocket.CLOSED) return;
|
|
140
|
+
// Close this connection only. Never unsubscribe, stop a daemon, or signal
|
|
141
|
+
// any process. A bounded forced close affects only this probe's socket.
|
|
142
|
+
await new Promise(resolve => {
|
|
143
|
+
const closing = setTimeout(() => { ws.terminate(); resolve(); }, 100);
|
|
144
|
+
ws.once('close', () => { clearTimeout(closing); resolve(); });
|
|
145
|
+
if (ws.readyState === WebSocket.CONNECTING) ws.terminate();
|
|
146
|
+
else ws.close();
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Inspect one explicitly selected public endpoint. This never creates an executor or resumes a thread. */
|
|
153
|
+
export async function probeCodexEndpoint(input) {
|
|
154
|
+
const options = validate(input);
|
|
155
|
+
const report = {
|
|
156
|
+
transportConnected: false, initialized: false, threadMatched: false, cwdMatched: false,
|
|
157
|
+
loaded: null, status: null, directInputAdvertised: null,
|
|
158
|
+
checks: { threadRead: false, loadedList: false },
|
|
159
|
+
writable: false, desktopEndpointVerified: false, unmetConditions: []
|
|
160
|
+
};
|
|
161
|
+
const addCondition = code => {
|
|
162
|
+
if (!report.unmetConditions.some(item => item.code === code)) report.unmetConditions.push({ code, message: messages[code] });
|
|
163
|
+
};
|
|
164
|
+
addCondition('DESKTOP_ENDPOINT_UNVERIFIED');
|
|
165
|
+
addCondition('READ_ONLY_PROBE');
|
|
166
|
+
let connection;
|
|
167
|
+
try {
|
|
168
|
+
const identity = socketIdentity(options.socketPath);
|
|
169
|
+
connection = connectProbe(options, identity, report, addCondition);
|
|
170
|
+
await connection.opened;
|
|
171
|
+
const initialized = await connection.request('initialize', {
|
|
172
|
+
clientInfo: { name: 'hello_cc_read_only_probe', title: 'hello-cc read-only endpoint probe', version: '1' },
|
|
173
|
+
capabilities: { experimentalApi: false }
|
|
174
|
+
});
|
|
175
|
+
if (!object(initialized) || !text(initialized.userAgent, 1024)) throw failure('PROTOCOL_INVALID');
|
|
176
|
+
connection.initialized();
|
|
177
|
+
report.initialized = true;
|
|
178
|
+
const result = await connection.request('thread/read', { threadId: options.threadId, includeTurns: false });
|
|
179
|
+
const thread = result?.thread;
|
|
180
|
+
if (!object(thread) || !text(thread.id, 512)) throw failure('PROTOCOL_INVALID');
|
|
181
|
+
report.checks.threadRead = true;
|
|
182
|
+
report.threadMatched = thread.id === options.threadId;
|
|
183
|
+
if (!report.threadMatched) { addCondition('THREAD_ID_MISMATCH'); return report; }
|
|
184
|
+
if (!text(thread.cwd, 4096) || !path.isAbsolute(thread.cwd) || !object(thread.status) || !statuses.has(thread.status.type)) throw failure('PROTOCOL_INVALID');
|
|
185
|
+
report.cwdMatched = path.normalize(thread.cwd) === path.normalize(options.expectedCwd);
|
|
186
|
+
report.status = thread.status.type;
|
|
187
|
+
report.directInputAdvertised = typeof thread.canAcceptDirectInput === 'boolean' ? thread.canAcceptDirectInput : null;
|
|
188
|
+
if (!report.cwdMatched) addCondition('CWD_MISMATCH');
|
|
189
|
+
if (report.directInputAdvertised !== true) addCondition('DIRECT_INPUT_UNAVAILABLE');
|
|
190
|
+
let cursor, found = false, complete = false;
|
|
191
|
+
const cursors = new Set();
|
|
192
|
+
for (let page = 0; page < options.maxPages; page++) {
|
|
193
|
+
const list = await connection.request('thread/loaded/list', { limit: options.pageSize, ...(cursor ? { cursor } : {}) });
|
|
194
|
+
if (!object(list) || !Array.isArray(list.data) || list.data.length > options.pageSize ||
|
|
195
|
+
!list.data.every(id => text(id, 512)) || (list.nextCursor !== null && !text(list.nextCursor, 4096))) throw failure('PROTOCOL_INVALID');
|
|
196
|
+
report.checks.loadedList = true;
|
|
197
|
+
found = list.data.includes(options.threadId);
|
|
198
|
+
if (found || list.nextCursor === null) { complete = true; break; }
|
|
199
|
+
if (cursors.has(list.nextCursor)) throw failure('PROTOCOL_INVALID');
|
|
200
|
+
cursors.add(list.nextCursor);
|
|
201
|
+
cursor = list.nextCursor;
|
|
202
|
+
}
|
|
203
|
+
if (!complete) addCondition('LOADED_LIST_INCOMPLETE');
|
|
204
|
+
else {
|
|
205
|
+
report.loaded = found && report.status !== 'notLoaded';
|
|
206
|
+
if (!report.loaded) addCondition('THREAD_NOT_LOADED');
|
|
207
|
+
if (found !== (report.status !== 'notLoaded')) addCondition('THREAD_STATE_CHANGED');
|
|
208
|
+
}
|
|
209
|
+
} catch (error) {
|
|
210
|
+
addCondition(Object.hasOwn(messages, error?.code) ? error.code : 'PROTOCOL_INVALID');
|
|
211
|
+
} finally { await connection?.close(); }
|
|
212
|
+
return report;
|
|
213
|
+
}
|
|
@@ -55,6 +55,23 @@ export function createDshCollaboration({ sessionId, cwd, maxContextChars = 16000
|
|
|
55
55
|
const tasks = createTaskStore({ now, addEvent, sendMessage: messages.sendMessage,
|
|
56
56
|
activePeerTtl: 600, observeClockSafety: evidence.observeTaskTakeoverClockSafety });
|
|
57
57
|
const coordination = createCoordinationState({ connect, now, ...messages, ...tasks, ...evidence });
|
|
58
|
+
// Idle delivery needs its own bounded snapshot. Exclude self-sent rows in
|
|
59
|
+
// SQL before LIMIT so a long self/broadcast backlog cannot hide new mail.
|
|
60
|
+
// Rendering and ACK candidates are derived together from this same query.
|
|
61
|
+
const externalCoordination = createCoordinationState({ connect, now, ...messages, ...tasks, ...evidence,
|
|
62
|
+
queryInbox(db, selectedPeer, includeAll, limit) {
|
|
63
|
+
if (selectedPeer !== peer) fail('The inbox peer identity changed');
|
|
64
|
+
return db.prepare(`
|
|
65
|
+
SELECT m.id, m.sender, m.recipient, m.task_id, m.kind, m.body,
|
|
66
|
+
m.reply_to, m.thread_id, m.created_at, r.read_at
|
|
67
|
+
FROM messages m
|
|
68
|
+
LEFT JOIN message_reads r ON r.message_id = m.id AND r.peer = ?
|
|
69
|
+
WHERE (m.recipient IS NULL OR m.recipient = '' OR m.recipient = 'all' OR m.recipient = ?)
|
|
70
|
+
AND (? = 1 OR r.read_at IS NULL) AND m.sender <> ?
|
|
71
|
+
ORDER BY m.id ASC LIMIT ?
|
|
72
|
+
`).all(peer, peer, includeAll ? 1 : 0, peer, limit);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
58
75
|
let disposed = false;
|
|
59
76
|
let retiring = false;
|
|
60
77
|
const identity = inspectProcessIdentity(process.pid).identity;
|
|
@@ -130,9 +147,9 @@ export function createDshCollaboration({ sessionId, cwd, maxContextChars = 16000
|
|
|
130
147
|
queue = run.catch(() => {});
|
|
131
148
|
return run;
|
|
132
149
|
}
|
|
133
|
-
function snapshot() {
|
|
150
|
+
function snapshot({ externalOnly = false } = {}) {
|
|
134
151
|
return owned(db => {
|
|
135
|
-
const state = coordination.buildHookCoordinationContext(db, ctx, peer);
|
|
152
|
+
const state = (externalOnly ? externalCoordination : coordination).buildHookCoordinationContext(db, ctx, peer);
|
|
136
153
|
const prefix = '[hello-cc Cordis collaboration]\nUse the hcc_* tools for this Agent. They fix the project and peer; do not impersonate other sessions.\n';
|
|
137
154
|
const text = prefix + redactSecrets(state.text);
|
|
138
155
|
const complete = text.length <= maxContextChars;
|
|
@@ -6,6 +6,8 @@ export interface DshCollaborationConfig {
|
|
|
6
6
|
maxContextChars?: number;
|
|
7
7
|
/** Bounded lossless-JSON tool presentation, 2048-64000 characters. Default 16000. */
|
|
8
8
|
maxToolChars?: number;
|
|
9
|
+
/** Idle inbox poll interval, 100-60000 ms. Default 1000; 0 disables automatic idle wakeup. */
|
|
10
|
+
inboxPollMs?: number;
|
|
9
11
|
}
|
|
10
12
|
export const name: 'hello-cc-dsh-collaboration';
|
|
11
13
|
export const inject: readonly ['agents', 'tools', 'sessions'];
|
|
@@ -5,6 +5,7 @@ import { createRequire } from 'node:module';
|
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
6
|
import { createDshCollaboration } from './dsh-collaboration.mjs';
|
|
7
7
|
import { scopedMcpToolDefinitions } from '../mcp/tools.mjs';
|
|
8
|
+
import { createDshInbox } from './dsh-inbox.mjs';
|
|
8
9
|
|
|
9
10
|
export const name = 'hello-cc-dsh-collaboration';
|
|
10
11
|
export const inject = ['agents', 'tools', 'sessions'];
|
|
@@ -88,7 +89,8 @@ function contextMessage(text) {
|
|
|
88
89
|
content: Object.freeze([Object.freeze({ type: 'text', text })]) });
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
export function createDshCordisPlugin({ checkRuntime = checkDshRuntime, createCollaboration = createDshCollaboration
|
|
92
|
+
export function createDshCordisPlugin({ checkRuntime = checkDshRuntime, createCollaboration = createDshCollaboration,
|
|
93
|
+
createInbox = createDshInbox } = {}) {
|
|
92
94
|
return function apply(ctx, config = {}) {
|
|
93
95
|
checkRuntime();
|
|
94
96
|
const bounds = {};
|
|
@@ -97,8 +99,11 @@ export function createDshCordisPlugin({ checkRuntime = checkDshRuntime, createCo
|
|
|
97
99
|
if (!Number.isSafeInteger(value) || value < 2048 || value > 64000) throw new TypeError(`${key} must be an integer from 2048 to 64000`);
|
|
98
100
|
bounds[key] = value;
|
|
99
101
|
}
|
|
102
|
+
const inboxPollMs = config.inboxPollMs ?? 1000;
|
|
103
|
+
if (!Number.isSafeInteger(inboxPollMs) || (inboxPollMs !== 0 && (inboxPollMs < 100 || inboxPollMs > 60000))) {
|
|
104
|
+
throw new TypeError('inboxPollMs must be 0 or an integer from 100 to 60000');
|
|
105
|
+
}
|
|
100
106
|
const states = new Map();
|
|
101
|
-
const pending = new Map();
|
|
102
107
|
let disposed = false;
|
|
103
108
|
function ensure(agent) {
|
|
104
109
|
if (disposed) throw new Error('hello-cc Cordis plugin has been disposed');
|
|
@@ -112,47 +117,43 @@ export function createDshCordisPlugin({ checkRuntime = checkDshRuntime, createCo
|
|
|
112
117
|
}
|
|
113
118
|
return state;
|
|
114
119
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
const inbox = createInbox({ ctx, ensure, contextMessage, pollMs: inboxPollMs });
|
|
121
|
+
ctx.on('dispose', () => {
|
|
122
|
+
disposed = true;
|
|
123
|
+
inbox.dispose();
|
|
124
|
+
for (const [agent, state] of states) {
|
|
125
|
+
try { retire(agent, state, { contextDisposed: true }, () => states.delete(agent)); }
|
|
126
|
+
catch (error) { ctx.logger?.warn(String(error)); }
|
|
127
|
+
}
|
|
128
|
+
});
|
|
123
129
|
function close(agent) {
|
|
124
130
|
disposedAgents.add(agent);
|
|
125
131
|
const state = states.get(agent) || failedRetirements.get(agent)?.state;
|
|
126
|
-
|
|
132
|
+
inbox.forget(agent);
|
|
127
133
|
if (state) retire(agent, state, { agentDisposed: true }, () => states.delete(agent));
|
|
128
134
|
}
|
|
129
135
|
// Awaited serial event; no detached setup races with the first model turn.
|
|
130
136
|
ctx.on('agent/created', async ({ agent, signal }) => { signal?.throwIfAborted(); ensure(agent); }, { prepend: true });
|
|
131
|
-
ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
|
|
137
|
+
ctx.on('agent/pre-step', async ({ agent, signal, messages }, next) => {
|
|
132
138
|
signal.throwIfAborted();
|
|
133
139
|
ensure(agent);
|
|
134
140
|
const decision = await next();
|
|
135
|
-
if (decision.kind !== 'enter') return decision;
|
|
136
141
|
signal.throwIfAborted();
|
|
137
|
-
|
|
138
|
-
return { ...decision, messages: [...decision.messages, message] };
|
|
142
|
+
return inbox.step(agent, decision, messages);
|
|
139
143
|
});
|
|
140
144
|
// ACK only after Harness commits the exact model-visible context. Rejected
|
|
141
145
|
// admission, failed preparation and cancelled turns leave the inbox unread.
|
|
142
|
-
ctx.on('session/event', (session, event) =>
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
146
|
+
ctx.on('session/event', (session, event) => inbox.commit(session, event));
|
|
147
|
+
ctx.on('agent/inbox/claimed', ({ agent, message }) => inbox.claimed(agent, message));
|
|
148
|
+
ctx.on('agent/inbox/discarded', ({ agent, message }) => inbox.discarded(agent, message));
|
|
149
|
+
ctx.on('agent/status', ({ agent, status }) => {
|
|
150
|
+
if (status === 'idle') inbox.idle(agent);
|
|
151
|
+
if (states.has(agent)) states.get(agent).status(status);
|
|
148
152
|
});
|
|
149
|
-
ctx.on('agent/status', ({ agent, status }) => { if (states.has(agent)) states.get(agent).status(status); });
|
|
150
153
|
ctx.on('agent/disposed', ({ agent }) => close(agent));
|
|
151
154
|
ctx.on('agent/turn-stopping', ({ agent, signal }) => {
|
|
152
155
|
signal.throwIfAborted();
|
|
153
|
-
|
|
154
|
-
if (snapshot.complete && snapshot.hasUnread) agent.steer(message);
|
|
155
|
-
else pending.delete(message.id);
|
|
156
|
+
inbox.steer(agent);
|
|
156
157
|
});
|
|
157
158
|
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
158
159
|
const decision = await next();
|
|
@@ -167,14 +168,6 @@ export function createDshCordisPlugin({ checkRuntime = checkDshRuntime, createCo
|
|
|
167
168
|
output: { schema: {}, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }] },
|
|
168
169
|
execute: async (args, exec) => ensure(exec.agent).call(tool.name, args, exec.signal)
|
|
169
170
|
});
|
|
170
|
-
ctx.on('dispose', () => {
|
|
171
|
-
disposed = true;
|
|
172
|
-
pending.clear();
|
|
173
|
-
for (const [agent, state] of states) {
|
|
174
|
-
try { retire(agent, state, { contextDisposed: true }, () => states.delete(agent)); }
|
|
175
|
-
catch (error) { ctx.logger?.warn(String(error)); }
|
|
176
|
-
}
|
|
177
|
-
});
|
|
178
171
|
};
|
|
179
172
|
}
|
|
180
173
|
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// Official Agent inbox delivery. A queued wake is not a read receipt: only the
|
|
2
|
+
// corresponding committed user/message can acknowledge coordination messages.
|
|
3
|
+
const registryKey = Symbol.for('@logicseek/hello-cc/dsh-inbox/receipts/v1');
|
|
4
|
+
if (!Object.hasOwn(globalThis, registryKey)) {
|
|
5
|
+
Object.defineProperty(globalThis, registryKey, { value: new WeakMap() });
|
|
6
|
+
}
|
|
7
|
+
const receipts = globalThis[registryKey];
|
|
8
|
+
const handoffKey = Symbol.for('@logicseek/hello-cc/dsh-inbox/handoffs/v1');
|
|
9
|
+
if (!Object.hasOwn(globalThis, handoffKey)) {
|
|
10
|
+
Object.defineProperty(globalThis, handoffKey, {
|
|
11
|
+
value: Object.freeze({ pending: new WeakMap(), blocked: new WeakMap() })
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
const handoffs = globalThis[handoffKey];
|
|
15
|
+
const signature = messages => JSON.stringify(messages.map(message => message.id));
|
|
16
|
+
const sameContents = (left, right) => isDeepStrictEqual(
|
|
17
|
+
[left.source, left.content], [right.source, right.content]);
|
|
18
|
+
|
|
19
|
+
export function createDshInbox({ ctx, ensure, contextMessage, pollMs,
|
|
20
|
+
schedule = setTimeout, unschedule = clearTimeout }) {
|
|
21
|
+
const pending = new Map(), agents = new Map();
|
|
22
|
+
let stopped = false, polling = false, timer;
|
|
23
|
+
|
|
24
|
+
function entryFor(agent) {
|
|
25
|
+
let entry = agents.get(agent);
|
|
26
|
+
if (!entry) {
|
|
27
|
+
entry = { wake: null, blocked: handoffs.blocked.get(agent) || null, warned: false };
|
|
28
|
+
agents.set(agent, entry);
|
|
29
|
+
}
|
|
30
|
+
return entry;
|
|
31
|
+
}
|
|
32
|
+
function warn(agent) {
|
|
33
|
+
const entry = entryFor(agent);
|
|
34
|
+
if (entry.warned) return;
|
|
35
|
+
entry.warned = true;
|
|
36
|
+
// Runtime errors can contain user paths or configuration. Log no payload.
|
|
37
|
+
try { ctx.logger?.warn('hello-cc inbox delivery deferred; it will retry after the runtime recovers'); } catch {}
|
|
38
|
+
}
|
|
39
|
+
function acknowledge(agent) {
|
|
40
|
+
const saved = receipts.get(agent);
|
|
41
|
+
if (!saved?.size) return;
|
|
42
|
+
const state = ensure(agent);
|
|
43
|
+
for (const [id, receipt] of saved) {
|
|
44
|
+
if (receipt.session !== agent.session) throw new Error('Cordis receipt Session changed');
|
|
45
|
+
state.ack(receipt.messages);
|
|
46
|
+
saved.delete(id);
|
|
47
|
+
}
|
|
48
|
+
receipts.delete(agent);
|
|
49
|
+
}
|
|
50
|
+
function clearPending(agent, except = null) {
|
|
51
|
+
for (const [id, delivery] of pending) {
|
|
52
|
+
if (delivery.agent === agent && id !== except && !delivery.queued) pending.delete(id);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function prepare(agent, externalOnly = false) {
|
|
56
|
+
acknowledge(agent);
|
|
57
|
+
const state = ensure(agent);
|
|
58
|
+
const snapshot = state.snapshot({ externalOnly });
|
|
59
|
+
const message = contextMessage(snapshot.text);
|
|
60
|
+
const delivery = { agent, session: agent.session, message, messages: snapshot.messages,
|
|
61
|
+
signature: signature(snapshot.messages),
|
|
62
|
+
externalSignature: signature(snapshot.messages.filter(item => item.sender !== state.peer)),
|
|
63
|
+
queued: false, claimed: false };
|
|
64
|
+
if (snapshot.messages.length) pending.set(message.id, delivery);
|
|
65
|
+
return { state, message, snapshot, delivery };
|
|
66
|
+
}
|
|
67
|
+
function block(agent, delivery) {
|
|
68
|
+
if (!delivery) return;
|
|
69
|
+
const entry = entryFor(agent);
|
|
70
|
+
entry.blocked = { all: delivery.signature, external: delivery.externalSignature };
|
|
71
|
+
handoffs.blocked.set(agent, entry.blocked);
|
|
72
|
+
if (entry.wake === delivery) entry.wake = null;
|
|
73
|
+
pending.delete(delivery.message.id);
|
|
74
|
+
}
|
|
75
|
+
function queued(agent, id) {
|
|
76
|
+
return [...(agent.inbox?.nextTurn || []), ...(agent.inbox?.nextStep || [])].some(message => message.id === id);
|
|
77
|
+
}
|
|
78
|
+
function live(agent) {
|
|
79
|
+
return !stopped && ctx.agents?.get(agent.session.header.id) === agent;
|
|
80
|
+
}
|
|
81
|
+
function adopt(agent) {
|
|
82
|
+
const carried = handoffs.pending.get(agent);
|
|
83
|
+
if (!carried) return;
|
|
84
|
+
const entry = entryFor(agent);
|
|
85
|
+
for (const delivery of carried.values()) {
|
|
86
|
+
if (delivery.session !== agent.session) throw new Error('Cordis pending Session changed');
|
|
87
|
+
pending.set(delivery.message.id, delivery);
|
|
88
|
+
if (delivery.queued) entry.wake = delivery;
|
|
89
|
+
// A commit during the reload gap cannot be inferred from idle. Preserve
|
|
90
|
+
// unread data and suppress an automatic replay of that uncertain batch.
|
|
91
|
+
if (agent.status === 'idle' && !queued(agent, delivery.message.id)) block(agent, delivery);
|
|
92
|
+
}
|
|
93
|
+
handoffs.pending.delete(agent);
|
|
94
|
+
}
|
|
95
|
+
function enqueue(agent, method) {
|
|
96
|
+
adopt(agent);
|
|
97
|
+
const entry = entryFor(agent);
|
|
98
|
+
if (entry.wake) return;
|
|
99
|
+
clearPending(agent);
|
|
100
|
+
const prepared = prepare(agent, method === 'followup');
|
|
101
|
+
const { state, snapshot, delivery, message } = prepared;
|
|
102
|
+
const blocked = method === 'followup'
|
|
103
|
+
? entry.blocked?.external === delivery.externalSignature
|
|
104
|
+
: entry.blocked?.all === delivery.signature;
|
|
105
|
+
if (!snapshot.complete || !snapshot.hasUnread || !snapshot.messages.length ||
|
|
106
|
+
(method === 'followup' && !snapshot.messages.some(item => item.sender !== state.peer)) ||
|
|
107
|
+
blocked) {
|
|
108
|
+
pending.delete(message.id);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// Snapshot verifies the exact project/peer owner immediately before send.
|
|
112
|
+
if (!live(agent) || (method === 'followup' &&
|
|
113
|
+
(agent.status !== 'idle' || agent.inbox?.hasPending))) {
|
|
114
|
+
pending.delete(message.id);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
entry.wake = delivery;
|
|
118
|
+
delivery.queued = true;
|
|
119
|
+
try { agent[method](message); }
|
|
120
|
+
catch (error) {
|
|
121
|
+
// A runtime failure after insertion must not enqueue a second copy.
|
|
122
|
+
if (!queued(agent, message.id) && !delivery.claimed) {
|
|
123
|
+
pending.delete(message.id);
|
|
124
|
+
if (entry.wake === delivery) entry.wake = null;
|
|
125
|
+
}
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function arm() {
|
|
130
|
+
if (!stopped && pollMs > 0) {
|
|
131
|
+
timer = schedule(poll, pollMs);
|
|
132
|
+
timer?.unref?.();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function poll() {
|
|
136
|
+
if (stopped || polling) return;
|
|
137
|
+
polling = true;
|
|
138
|
+
try {
|
|
139
|
+
// Also discovers already-live Agents after profile hot reload. It never
|
|
140
|
+
// resumes cold sessions or creates a provider process.
|
|
141
|
+
for (const agent of ctx.agents?.list() || []) {
|
|
142
|
+
if (!live(agent)) continue;
|
|
143
|
+
try {
|
|
144
|
+
ensure(agent);
|
|
145
|
+
adopt(agent);
|
|
146
|
+
acknowledge(agent);
|
|
147
|
+
if (agent.status === 'idle' && !agent.inbox?.hasPending) enqueue(agent, 'followup');
|
|
148
|
+
entryFor(agent).warned = false;
|
|
149
|
+
} catch { warn(agent); }
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// Registry enumeration can fail during Host disposal; the next poll can
|
|
153
|
+
// recover without an uncaught timer exception or an overlapping scan.
|
|
154
|
+
} finally {
|
|
155
|
+
polling = false;
|
|
156
|
+
arm();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
arm();
|
|
160
|
+
return {
|
|
161
|
+
step(agent, decision, proposed = []) {
|
|
162
|
+
adopt(agent);
|
|
163
|
+
const entry = entryFor(agent), wake = entry.wake;
|
|
164
|
+
ensure(agent);
|
|
165
|
+
if (decision.kind !== 'enter') {
|
|
166
|
+
if (wake && proposed.some(message => message.id === wake.message.id)) block(agent, wake);
|
|
167
|
+
return decision;
|
|
168
|
+
}
|
|
169
|
+
acknowledge(agent);
|
|
170
|
+
// Keep the exact queued context once. The downstream admission chain may
|
|
171
|
+
// remove it; do not put rejected content back or acknowledge altered text.
|
|
172
|
+
if (wake && proposed.some(message => message.id === wake.message.id)) {
|
|
173
|
+
const accepted = decision.messages.find(message => message.id === wake.message.id);
|
|
174
|
+
if (!accepted || !sameContents(accepted, wake.message)) block(agent, wake);
|
|
175
|
+
else ensure(agent).snapshot();
|
|
176
|
+
return decision;
|
|
177
|
+
}
|
|
178
|
+
clearPending(agent);
|
|
179
|
+
const { message } = prepare(agent);
|
|
180
|
+
return { ...decision, messages: [...decision.messages, message] };
|
|
181
|
+
},
|
|
182
|
+
commit(session, event) {
|
|
183
|
+
if (stopped || event.type !== 'user/message') return;
|
|
184
|
+
if (!pending.has(event.data.id)) {
|
|
185
|
+
const agent = ctx.agents?.get(session.header.id);
|
|
186
|
+
if (agent?.session === session) adopt(agent);
|
|
187
|
+
}
|
|
188
|
+
const delivery = pending.get(event.data.id);
|
|
189
|
+
if (!delivery || delivery.session !== session || !sameContents(event.data, delivery.message)) return;
|
|
190
|
+
pending.delete(event.data.id);
|
|
191
|
+
const entry = entryFor(delivery.agent);
|
|
192
|
+
if (entry.wake === delivery) entry.wake = null;
|
|
193
|
+
entry.blocked = null;
|
|
194
|
+
handoffs.blocked.delete(delivery.agent);
|
|
195
|
+
const saved = receipts.get(delivery.agent) || new Map();
|
|
196
|
+
saved.set(event.data.id, delivery);
|
|
197
|
+
receipts.set(delivery.agent, saved);
|
|
198
|
+
try { acknowledge(delivery.agent); } catch { warn(delivery.agent); }
|
|
199
|
+
},
|
|
200
|
+
claimed(agent, message) {
|
|
201
|
+
adopt(agent);
|
|
202
|
+
const delivery = pending.get(message.id);
|
|
203
|
+
if (delivery?.agent === agent) delivery.claimed = true;
|
|
204
|
+
},
|
|
205
|
+
discarded(agent, message) {
|
|
206
|
+
if (stopped) return;
|
|
207
|
+
adopt(agent);
|
|
208
|
+
const delivery = pending.get(message.id);
|
|
209
|
+
if (delivery?.agent === agent) block(agent, delivery);
|
|
210
|
+
},
|
|
211
|
+
idle(agent) {
|
|
212
|
+
adopt(agent);
|
|
213
|
+
// Failed preparation/cancellation after claim leaves unread input. Wait
|
|
214
|
+
// for explicit work or a different unread batch instead of a prompt loop.
|
|
215
|
+
for (const delivery of pending.values()) {
|
|
216
|
+
if (delivery.agent === agent && (delivery.claimed || !delivery.queued)) block(agent, delivery);
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
steer(agent) { enqueue(agent, 'steer'); },
|
|
220
|
+
forget(agent) {
|
|
221
|
+
const wake = agents.get(agent)?.wake;
|
|
222
|
+
if (wake && queued(agent, wake.message.id)) {
|
|
223
|
+
try { agent.inbox.remove(wake.message.id); } catch { warn(agent); }
|
|
224
|
+
}
|
|
225
|
+
for (const [id, delivery] of pending) if (delivery.agent === agent) pending.delete(id);
|
|
226
|
+
agents.delete(agent);
|
|
227
|
+
handoffs.pending.delete(agent);
|
|
228
|
+
handoffs.blocked.delete(agent);
|
|
229
|
+
},
|
|
230
|
+
dispose() {
|
|
231
|
+
stopped = true;
|
|
232
|
+
unschedule(timer);
|
|
233
|
+
for (const agent of [...agents.keys()]) {
|
|
234
|
+
const wake = agents.get(agent)?.wake;
|
|
235
|
+
if (wake && queued(agent, wake.message.id)) {
|
|
236
|
+
try {
|
|
237
|
+
if (agent.inbox.remove(wake.message.id)) pending.delete(wake.message.id);
|
|
238
|
+
} catch { warn(agent); }
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
for (const delivery of pending.values()) {
|
|
242
|
+
const carried = handoffs.pending.get(delivery.agent) || new Map();
|
|
243
|
+
carried.set(delivery.message.id, delivery);
|
|
244
|
+
handoffs.pending.set(delivery.agent, carried);
|
|
245
|
+
}
|
|
246
|
+
pending.clear();
|
|
247
|
+
agents.clear();
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
import { isDeepStrictEqual } from 'node:util';
|
package/lib/ui/help.mjs
CHANGED
|
@@ -16,6 +16,7 @@ Usage:
|
|
|
16
16
|
Commands:
|
|
17
17
|
web Start coordination, shims, tmux, and browser console
|
|
18
18
|
dsh <setup|status|web> Connect DeepSeek Harness through project-local hooks
|
|
19
|
+
app <claude|codex> Bridge opt-in Desktop sessions or probe an explicit endpoint
|
|
19
20
|
native <subcommand> Run owned Codex/Claude/dsh workers through native protocols
|
|
20
21
|
up Initialize local coordination only
|
|
21
22
|
down Stop the running hello-cc web runtime
|