@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.
@@ -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
- function context(agent) {
116
- const state = ensure(agent);
117
- for (const [id, entry] of pending) if (entry.session === agent.session) pending.delete(id);
118
- const snapshot = state.snapshot();
119
- const message = contextMessage(snapshot.text);
120
- if (snapshot.messages.length) pending.set(message.id, { session: agent.session, state, messages: snapshot.messages });
121
- return { message, snapshot };
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
- for (const [id, entry] of pending) if (entry.session === agent.session) pending.delete(id);
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
- const { message } = context(agent);
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
- if (event.type !== 'user/message') return;
144
- const entry = pending.get(event.data.id);
145
- if (!entry || entry.session !== session) return;
146
- pending.delete(event.data.id);
147
- entry.state.ack(entry.messages);
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
- const { message, snapshot } = context(agent);
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logicseek/hello-cc",
3
- "version": "1.1.0-rc.4",
3
+ "version": "1.1.0-rc.6",
4
4
  "description": "Local control plane for coordinating Claude Code, Codex, and other coding CLI sessions.",
5
5
  "type": "module",
6
6
  "bin": {