pendingyou 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -11
- package/README.md +65 -20
- package/dist/api.js +0 -50
- package/dist/apps/claude-code.js +0 -38
- package/dist/apps/codex-app.js +0 -18
- package/dist/apps/codex-wake.js +290 -122
- package/dist/apps/codex.js +0 -68
- package/dist/apps/ids.js +0 -11
- package/dist/apps/opencode-plugin.js +39 -65
- package/dist/apps/opencode.js +0 -70
- package/dist/apps/pi-extension.js +60 -75
- package/dist/apps/pi.js +0 -62
- package/dist/apps/registry.js +0 -2
- package/dist/args.js +7 -40
- package/dist/bridge.js +0 -94
- package/dist/claude.js +0 -94
- package/dist/cli.js +0 -2
- package/dist/confirm.js +0 -69
- package/dist/credentials.js +0 -27
- package/dist/environment.js +0 -28
- package/dist/errors.js +0 -1
- package/dist/files.js +0 -16
- package/dist/format.js +30 -13
- package/dist/handed.js +71 -0
- package/dist/hold.js +0 -24
- package/dist/hooks.js +0 -26
- package/dist/index.js +0 -2
- package/dist/init.js +0 -32
- package/dist/install.js +0 -32
- package/dist/io.js +0 -10
- package/dist/json.js +0 -29
- package/dist/jsonc.js +0 -33
- package/dist/main.js +3 -26
- package/dist/mod.js +0 -23
- package/dist/oauth.js +0 -45
- package/dist/permission.js +0 -107
- package/dist/pickup.js +101 -62
- package/dist/qr.js +0 -28
- package/dist/redact.js +0 -77
- package/dist/remote.js +0 -90
- package/dist/setup.js +0 -30
- package/dist/shim.js +0 -23
- package/dist/signin.js +0 -42
- package/dist/state.js +19 -10
- package/dist/stopcheck.js +40 -84
- package/dist/toml.js +0 -34
- package/dist/version.js +1 -2
- package/dist/watch.js +3 -20
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/README.md +2 -2
- package/mod/hooks/register.js +104 -61
- package/mod/hooks/wake.js +110 -108
- package/package.json +2 -1
- package/mod/tests/wake.test.ts +0 -247
package/dist/apps/codex-wake.js
CHANGED
|
@@ -1,88 +1,79 @@
|
|
|
1
|
-
// Codex hearing answers (0.11.0), and OpenCode and Pi the same way (0.12.0): what an app's hooks remember about the
|
|
2
|
-
// cards a thread (OpenCode, Pi: a session) posted, and the listener that wakes the thread when one is answered.
|
|
3
|
-
//
|
|
4
|
-
// - `pendingyou posted --app codex` is Codex's PostToolUse hook for Pending You's card tools (post_request,
|
|
5
|
-
// update_request, reply_in_thread, cancel_request; Codex runs it only for a call that didn't fail). It records that
|
|
6
|
-
// the turn put something in front of the person (with the card's words, for the Stop check: stopcheck.ts), and which
|
|
7
|
-
// thread is waiting on which card (`session_id` is the thread's id), then makes sure that thread has a listener.
|
|
8
|
-
// OpenCode's plugin runs it from `tool.execute.after` (apps/opencode-plugin.ts), and Pi's extension from Pi's
|
|
9
|
-
// `tool_result` (apps/pi-extension.ts), with the same fields.
|
|
10
|
-
// - `pendingyou listen --app codex --thread <id>` is that listener: one per thread (a lease file), detached, holding no
|
|
11
|
-
// hook's output (Codex would wait for it otherwise). It long-polls Pending You with the app's own connection, as
|
|
12
|
-
// `watch` does, and when one of the thread's cards is the agent's move (an answer, a message, a fallback that ran) it
|
|
13
|
-
// runs `codex queue --thread <id> --message "Pending You: your person answered …"`, in the words the Claude Code wake
|
|
14
|
-
// mod uses. Never the answer itself: a program's arguments are there for any other program on the computer to read,
|
|
15
|
-
// so the agent reads the answer with get_request, as the message says. Codex starts a turn at once when the thread is
|
|
16
|
-
// loaded and idle, after the current one when it's working, or when it's next resumed. It stops when the thread has
|
|
17
|
-
// no card left waiting, or after 8 hours; the next session start or message starts it again while one is (pickup.ts).
|
|
18
|
-
// - OpenCode's listener is the plugin's child, not detached, since only the plugin can start a turn
|
|
19
|
-
// (client.session.promptAsync): it writes each wake as one JSON line on stdout, `{"wake": "<the words>",
|
|
20
|
-
// "requests": ["req_…"]}`, and the plugin answers on its stdin, `{"delivered": true}` once the turn is started (or
|
|
21
|
-
// false). Only then is it marked handed over. It stops when its stdin closes (OpenCode went), as well as when Codex's
|
|
22
|
-
// would. Pi's listener is its extension's child in just the same way (`pi.sendMessage` starts the turn).
|
|
23
|
-
//
|
|
24
|
-
// Ids, names and titles only, in ~/.config/pendingyou/<app>-threads.json: never an answer.
|
|
25
1
|
import { randomBytes } from 'node:crypto';
|
|
26
2
|
import { mkdir, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises';
|
|
27
3
|
import { join } from 'node:path';
|
|
28
4
|
import { getJson, SignInNeeded, Unavailable } from "../api.js";
|
|
29
5
|
import { DEFAULT_ORIGIN } from "../args.js";
|
|
30
|
-
import { readCredential } from "../credentials.js";
|
|
6
|
+
import { connectionMachine, readCredential } from "../credentials.js";
|
|
31
7
|
import { configDir, readJson, withLock, writeWhole } from "../files.js";
|
|
32
|
-
import { clip, isClosed } from "../format.js";
|
|
33
|
-
import {
|
|
8
|
+
import { clip, delegatedOf, isClosed } from "../format.js";
|
|
9
|
+
import { chooseThread, goesBy, HANDED_FOR_MS, handedCard, handedMessage, namesAnotherComputer, } from "../handed.js";
|
|
10
|
+
import { macName, tailscaleName } from "../remote.js";
|
|
11
|
+
import { claimHanded, markHanded, momentOf, releaseHanded, wasHanded } from "../state.js";
|
|
34
12
|
import { findCodex } from "./codex-app.js";
|
|
35
|
-
|
|
13
|
+
import { APP_IDS } from "./ids.js";
|
|
36
14
|
const KEEP_TURN_MS = 24 * 60 * 60_000;
|
|
37
15
|
const KEEP_THREAD_MS = 7 * 24 * 60 * 60_000;
|
|
38
|
-
/** The most of one card's words a turn keeps, to match an ask against (stopcheck.ts). */
|
|
39
16
|
const CARD_WORDS = 20_000;
|
|
40
|
-
/** How long a listener waits on one thread, at most, and how often it renews its lease. */
|
|
41
17
|
export const LISTEN_MS = 8 * 60 * 60_000;
|
|
42
18
|
const LONG_POLL_SECONDS = 25;
|
|
43
|
-
/** A lease untouched for this long belongs to a listener that died. */
|
|
44
19
|
const LEASE_STALE_MS = 90_000;
|
|
45
20
|
const PATIENT_AFTER_MS = 30 * 60_000;
|
|
46
21
|
const PATIENT_PAUSE_MS = 30_000;
|
|
47
22
|
const BACKOFF_MS = [2000, 5000, 10_000, 20_000, 30_000, 60_000];
|
|
48
|
-
/** How long OpenCode's listener waits for the plugin to say whether it started the turn. */
|
|
49
23
|
const REPLY_MS = 60_000;
|
|
50
24
|
const REQUEST_ID = /^req_[A-Za-z0-9-]{1,40}$/;
|
|
51
|
-
/** A thread's id: Codex's (a UUID) or an OpenCode session's (`ses_…`). */
|
|
52
25
|
export const THREAD = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
* The card calls the hook reads, on a Pending You server: Codex and Pi name a tool `mcp__pendingyou__post_request`,
|
|
57
|
-
* OpenCode `pendingyou_post_request` (the server's name, `_`, the tool's).
|
|
58
|
-
*/
|
|
26
|
+
const HANDED = '_handed';
|
|
27
|
+
const NAMES = 5;
|
|
28
|
+
const MCP_CARD_TOOL = /^mcp__([A-Za-z0-9_]*pendingyou[A-Za-z0-9_]*)__(post_request|update_request|reply_in_thread|cancel_request|answer_delegated|hand_back)$/i;
|
|
59
29
|
const CARD_TOOLS = {
|
|
60
30
|
codex: MCP_CARD_TOOL,
|
|
61
|
-
opencode: /^([A-Za-z0-9_-]*pendingyou[A-Za-z0-9_-]*)_(post_request|update_request|reply_in_thread|cancel_request)$/i,
|
|
31
|
+
opencode: /^([A-Za-z0-9_-]*pendingyou[A-Za-z0-9_-]*)_(post_request|update_request|reply_in_thread|cancel_request|answer_delegated|hand_back)$/i,
|
|
62
32
|
pi: MCP_CARD_TOOL,
|
|
63
33
|
};
|
|
64
|
-
|
|
34
|
+
const HELPER_CALLS = new Set(['answer_delegated', 'hand_back']);
|
|
65
35
|
export function cardTool(app, name) {
|
|
66
36
|
const pattern = CARD_TOOLS[app];
|
|
67
37
|
return pattern && typeof name === 'string' ? (pattern.exec(name)?.[2] ?? null) : null;
|
|
68
38
|
}
|
|
69
39
|
const statePath = (io, app) => join(configDir(io), `${app}-threads.json`);
|
|
70
40
|
const leasePath = (io, app, thread) => join(configDir(io), `${app}-listen`, `${thread}.lease`);
|
|
71
|
-
/** What uninstall takes away for an app: its threads' record and its listeners' leases. */
|
|
72
41
|
export const threadFiles = (io, app) => [
|
|
73
42
|
statePath(io, app),
|
|
74
43
|
join(configDir(io), `${app}-listen`),
|
|
75
44
|
];
|
|
76
45
|
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
46
|
+
function threadOf(value) {
|
|
47
|
+
if (!isObject(value) || typeof value.at !== 'number' || !Number.isFinite(value.at))
|
|
48
|
+
return null;
|
|
49
|
+
const names = Array.isArray(value.names)
|
|
50
|
+
? value.names.filter((name) => typeof name === 'string' && name.trim() !== '')
|
|
51
|
+
: [];
|
|
52
|
+
return {
|
|
53
|
+
origin: typeof value.origin === 'string' ? value.origin : DEFAULT_ORIGIN,
|
|
54
|
+
cards: isObject(value.cards) ? value.cards : {},
|
|
55
|
+
at: value.at,
|
|
56
|
+
...(names.length ? { names: names.slice(-NAMES) } : {}),
|
|
57
|
+
...(typeof value.cwd === 'string' && value.cwd ? { cwd: value.cwd } : {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
77
60
|
async function load(io, app) {
|
|
78
61
|
const file = await readJson(statePath(io, app)).catch(() => null);
|
|
62
|
+
const threads = {};
|
|
63
|
+
if (isObject(file?.threads))
|
|
64
|
+
for (const [id, value] of Object.entries(file.threads)) {
|
|
65
|
+
const thread = threadOf(value);
|
|
66
|
+
if (thread)
|
|
67
|
+
threads[id] = thread;
|
|
68
|
+
}
|
|
79
69
|
return {
|
|
80
70
|
version: 1,
|
|
81
|
-
threads
|
|
71
|
+
threads,
|
|
82
72
|
turns: isObject(file?.turns) ? file.turns : {},
|
|
83
73
|
};
|
|
84
74
|
}
|
|
85
|
-
|
|
75
|
+
const mayBeHanded = (thread, now) => Boolean(thread.names?.length) && now - thread.at < HANDED_FOR_MS;
|
|
76
|
+
const listenable = (thread, now) => thread !== undefined && (Object.keys(thread.cards).length > 0 || mayBeHanded(thread, now));
|
|
86
77
|
async function change(io, app, edit) {
|
|
87
78
|
const path = statePath(io, app);
|
|
88
79
|
return withLock(path, io, async () => {
|
|
@@ -93,13 +84,12 @@ async function change(io, app, edit) {
|
|
|
93
84
|
if (!(now - turn.at < KEEP_TURN_MS))
|
|
94
85
|
delete state.turns[id];
|
|
95
86
|
for (const [id, thread] of Object.entries(state.threads))
|
|
96
|
-
if (!(now - thread.at < KEEP_THREAD_MS) ||
|
|
87
|
+
if (!(now - thread.at < KEEP_THREAD_MS) || !listenable(thread, now))
|
|
97
88
|
delete state.threads[id];
|
|
98
89
|
await writeWhole(path, `${JSON.stringify(state, null, 2)}\n`, { secret: true });
|
|
99
90
|
return state;
|
|
100
91
|
});
|
|
101
92
|
}
|
|
102
|
-
/** A tool's answer as Codex hands it to PostToolUse (a CallToolResult): its JSON object, or null. */
|
|
103
93
|
export function resultOf(response) {
|
|
104
94
|
if (!isObject(response) || response.isError === true)
|
|
105
95
|
return null;
|
|
@@ -118,7 +108,6 @@ export function resultOf(response) {
|
|
|
118
108
|
}
|
|
119
109
|
return null;
|
|
120
110
|
}
|
|
121
|
-
/** Every string a call carried (a card's title, summary, steps…), up to `max` characters: what a card says. */
|
|
122
111
|
export function wordsIn(value, max = CARD_WORDS) {
|
|
123
112
|
const found = [];
|
|
124
113
|
let left = max;
|
|
@@ -140,16 +129,16 @@ export function wordsIn(value, max = CARD_WORDS) {
|
|
|
140
129
|
return found.join('\n');
|
|
141
130
|
}
|
|
142
131
|
const text = (value, max) => typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : undefined;
|
|
143
|
-
/** The agent's name on a call: `name`, or post_request's `session.label`. */
|
|
144
132
|
function nameOf(input) {
|
|
145
133
|
const session = isObject(input.session) ? input.session : null;
|
|
146
134
|
return text(input.name, 120) ?? text(session?.label, 120);
|
|
147
135
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
136
|
+
function withName(thread, name) {
|
|
137
|
+
if (!name)
|
|
138
|
+
return thread;
|
|
139
|
+
const names = (thread.names ?? []).filter((each) => !goesBy([each], name));
|
|
140
|
+
return { ...thread, names: [...names, name].slice(-NAMES) };
|
|
141
|
+
}
|
|
153
142
|
export async function posted(io, options) {
|
|
154
143
|
const { app } = options;
|
|
155
144
|
try {
|
|
@@ -164,36 +153,38 @@ export async function posted(io, options) {
|
|
|
164
153
|
const args = isObject(input.tool_input) ? input.tool_input : {};
|
|
165
154
|
const output = resultOf(input.tool_response);
|
|
166
155
|
const requestId = [output?.requestId, args.requestId].find((id) => typeof id === 'string' && REQUEST_ID.test(id));
|
|
156
|
+
const cwd = text(input.cwd, 1000);
|
|
167
157
|
const now = io.now();
|
|
168
|
-
|
|
158
|
+
await change(io, app, (state) => {
|
|
169
159
|
if (turn && call !== 'cancel_request') {
|
|
170
160
|
const record = state.turns[turn] ?? { at: now, cards: [] };
|
|
171
161
|
record.cards = [...record.cards, wordsIn(args)].slice(-10);
|
|
172
162
|
record.at = now;
|
|
173
163
|
state.turns[turn] = record;
|
|
174
164
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
};
|
|
165
|
+
const waiting = withName(state.threads[thread] ?? { origin: options.origin, cards: {}, at: now }, nameOf(args));
|
|
166
|
+
if (requestId && !HELPER_CALLS.has(call)) {
|
|
167
|
+
const reopen = isObject(args.reopen) ? args.reopen : null;
|
|
168
|
+
if (call === 'cancel_request')
|
|
169
|
+
delete waiting.cards[requestId];
|
|
170
|
+
else if (call !== 'reply_in_thread' || reopen || output?.turn === 'you') {
|
|
171
|
+
const before = waiting.cards[requestId];
|
|
172
|
+
const name = nameOf(args) ?? before?.name;
|
|
173
|
+
const title = text(args.title, 400) ?? text(reopen?.title, 400) ?? before?.title;
|
|
174
|
+
waiting.cards[requestId] = {
|
|
175
|
+
...(name ? { name } : {}),
|
|
176
|
+
...(title ? { title } : {}),
|
|
177
|
+
at: now,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
191
180
|
}
|
|
181
|
+
if (cwd)
|
|
182
|
+
waiting.cwd = cwd;
|
|
192
183
|
waiting.origin = options.origin;
|
|
193
184
|
waiting.at = now;
|
|
194
185
|
state.threads[thread] = waiting;
|
|
195
186
|
});
|
|
196
|
-
if (app === 'codex'
|
|
187
|
+
if (app === 'codex')
|
|
197
188
|
await keepListening(io, options.origin, thread);
|
|
198
189
|
return 0;
|
|
199
190
|
}
|
|
@@ -201,48 +192,118 @@ export async function posted(io, options) {
|
|
|
201
192
|
return 0;
|
|
202
193
|
}
|
|
203
194
|
}
|
|
204
|
-
|
|
195
|
+
export async function touchThread(io, app, thread, cwd) {
|
|
196
|
+
try {
|
|
197
|
+
if (!THREAD.test(thread) || !(await load(io, app)).threads[thread])
|
|
198
|
+
return;
|
|
199
|
+
const now = io.now();
|
|
200
|
+
await change(io, app, (state) => {
|
|
201
|
+
const known = state.threads[thread];
|
|
202
|
+
if (!known)
|
|
203
|
+
return;
|
|
204
|
+
known.at = now;
|
|
205
|
+
if (cwd)
|
|
206
|
+
known.cwd = cwd;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
catch { }
|
|
210
|
+
}
|
|
211
|
+
export async function threadNames(io, app, thread) {
|
|
212
|
+
if (!thread)
|
|
213
|
+
return [];
|
|
214
|
+
return (await load(io, app).catch(() => null))?.threads[thread]?.names ?? [];
|
|
215
|
+
}
|
|
216
|
+
export async function markHandedTurn(io, turn, app) {
|
|
217
|
+
const now = io.now();
|
|
218
|
+
await change(io, app, (state) => {
|
|
219
|
+
state.turns[turn] = { ...(state.turns[turn] ?? { cards: [] }), at: now, delegated: true };
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
async function candidates(io, app) {
|
|
223
|
+
const now = io.now();
|
|
224
|
+
const found = [];
|
|
225
|
+
for (const [thread, record] of Object.entries((await load(io, app)).threads)) {
|
|
226
|
+
if (!listenable(record, now))
|
|
227
|
+
continue;
|
|
228
|
+
found.push({
|
|
229
|
+
thread,
|
|
230
|
+
names: record.names ?? [],
|
|
231
|
+
...(record.cwd ? { cwd: record.cwd } : {}),
|
|
232
|
+
at: record.at,
|
|
233
|
+
live: app === 'codex' || (await listening(io, app, thread)),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return found;
|
|
237
|
+
}
|
|
238
|
+
export async function hearsOnly(io, origin, app) {
|
|
239
|
+
const credential = await readCredential(io, origin, app).catch(() => null);
|
|
240
|
+
return credential !== null && credential.kind !== 'connection';
|
|
241
|
+
}
|
|
242
|
+
export async function computerNames(io, origin, options) {
|
|
243
|
+
const names = [io.host];
|
|
244
|
+
for (const app of APP_IDS) {
|
|
245
|
+
const machine = await connectionMachine(io, origin, app).catch(() => null);
|
|
246
|
+
if (machine)
|
|
247
|
+
names.push(machine);
|
|
248
|
+
}
|
|
249
|
+
const mac = await macName({ run: io.run, platform: io.platform ?? 'linux' }).catch(() => null);
|
|
250
|
+
if (mac)
|
|
251
|
+
names.push(mac);
|
|
252
|
+
if (options.tailscale) {
|
|
253
|
+
const tailscale = await tailscaleName(io).catch(() => null);
|
|
254
|
+
if (tailscale)
|
|
255
|
+
names.push(tailscale);
|
|
256
|
+
}
|
|
257
|
+
return names;
|
|
258
|
+
}
|
|
259
|
+
async function elsewhereFor(io, origin, app) {
|
|
260
|
+
if (!(await hearsOnly(io, origin, app)))
|
|
261
|
+
return () => false;
|
|
262
|
+
const names = await computerNames(io, origin, { tailscale: true });
|
|
263
|
+
return (heard) => namesAnotherComputer(heard.machine, names);
|
|
264
|
+
}
|
|
265
|
+
export async function sessionFor(io, app, heard) {
|
|
266
|
+
return chooseThread(heard, await candidates(io, app), { home: io.home });
|
|
267
|
+
}
|
|
205
268
|
export async function turnRecord(io, turn, app = 'codex') {
|
|
206
269
|
const record = (await load(io, app)).turns[turn];
|
|
207
|
-
return record
|
|
270
|
+
return record
|
|
271
|
+
? {
|
|
272
|
+
cards: Array.isArray(record.cards) ? record.cards : [],
|
|
273
|
+
nudged: record.nudged === true,
|
|
274
|
+
delegated: record.delegated === true,
|
|
275
|
+
}
|
|
276
|
+
: null;
|
|
208
277
|
}
|
|
209
|
-
/** Remembers that the Stop check asked this turn to go on: at most once a turn. */
|
|
210
278
|
export async function markNudged(io, turn, app = 'codex') {
|
|
211
279
|
const now = io.now();
|
|
212
280
|
await change(io, app, (state) => {
|
|
213
281
|
state.turns[turn] = { ...(state.turns[turn] ?? { cards: [] }), at: now, nudged: true };
|
|
214
282
|
});
|
|
215
283
|
}
|
|
216
|
-
/** Whether a listener holds this thread now: its lease, touched within LEASE_STALE_MS. */
|
|
217
284
|
async function listening(io, app, thread) {
|
|
218
285
|
return stat(leasePath(io, app, thread)).then((info) => Date.now() - info.mtimeMs < LEASE_STALE_MS, () => false);
|
|
219
286
|
}
|
|
220
|
-
/**
|
|
221
|
-
* Makes sure a Codex thread with cards waiting on the person has a listener: starts one, detached, unless one is there
|
|
222
|
-
* or Codex isn't signed in. Quick (three small reads) and never throws: the session-start and next-message hooks call
|
|
223
|
-
* it too.
|
|
224
|
-
*/
|
|
225
287
|
export async function keepListening(io, origin, thread) {
|
|
226
288
|
try {
|
|
227
289
|
if (!THREAD.test(thread))
|
|
228
290
|
return;
|
|
229
|
-
const
|
|
230
|
-
|
|
291
|
+
const { threads } = await load(io, 'codex');
|
|
292
|
+
const now = io.now();
|
|
293
|
+
const own = Object.keys(threads[thread]?.cards ?? {}).length > 0;
|
|
294
|
+
const handed = Object.values(threads).some((each) => mayBeHanded(each, now));
|
|
295
|
+
if (!own && !handed)
|
|
231
296
|
return;
|
|
232
297
|
if (!(await readCredential(io, origin, 'codex')))
|
|
233
298
|
return;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
'--app',
|
|
237
|
-
|
|
238
|
-
'--
|
|
239
|
-
thread,
|
|
240
|
-
...(origin === DEFAULT_ORIGIN ? [] : ['--origin', origin]),
|
|
241
|
-
]);
|
|
299
|
+
const at = origin === DEFAULT_ORIGIN ? [] : ['--origin', origin];
|
|
300
|
+
if (own && !(await listening(io, 'codex', thread)))
|
|
301
|
+
io.background(['listen', '--app', 'codex', '--thread', thread, ...at]);
|
|
302
|
+
if (handed && !(await listening(io, 'codex', HANDED)))
|
|
303
|
+
io.background(['listen', '--app', 'codex', '--handed', ...at]);
|
|
242
304
|
}
|
|
243
305
|
catch { }
|
|
244
306
|
}
|
|
245
|
-
/** What happened to a card and what to do, in the words the Claude Code wake mod uses (wake.ts's line). */
|
|
246
307
|
function line(heard, card) {
|
|
247
308
|
const titled = `“${clip(card.title ?? heard.title, 80)
|
|
248
309
|
.text.replaceAll('“', '‘')
|
|
@@ -271,7 +332,6 @@ function line(heard, card) {
|
|
|
271
332
|
}
|
|
272
333
|
}
|
|
273
334
|
const capital = (sentence) => sentence.charAt(0).toUpperCase() + sentence.slice(1);
|
|
274
|
-
/** The message that wakes the thread: every card that needs it, and what to do with each; null when none does. */
|
|
275
335
|
export function wakeMessage(ready) {
|
|
276
336
|
const lines = ready.flatMap(({ heard, card }) => {
|
|
277
337
|
const said = line(heard, card);
|
|
@@ -293,10 +353,6 @@ export function wakeMessage(ready) {
|
|
|
293
353
|
...(reopen ? [reopen.trim()] : []),
|
|
294
354
|
].join('\n');
|
|
295
355
|
}
|
|
296
|
-
/**
|
|
297
|
-
* Takes the thread's lease, a file holding this listener's own id: null when a live listener holds it. Two listeners
|
|
298
|
-
* starting at once may both write it; the one whose id isn't there at its next renewal stops.
|
|
299
|
-
*/
|
|
300
356
|
async function takeLease(io, app, thread) {
|
|
301
357
|
const path = leasePath(io, app, thread);
|
|
302
358
|
await mkdir(join(path, '..'), { recursive: true, mode: 0o700 });
|
|
@@ -312,7 +368,6 @@ async function takeLease(io, app, thread) {
|
|
|
312
368
|
return null;
|
|
313
369
|
}
|
|
314
370
|
}
|
|
315
|
-
/** Renews the lease while it's still this listener's (`id`); false once another listener has taken it. */
|
|
316
371
|
async function renew(io, app, thread, id) {
|
|
317
372
|
const path = leasePath(io, app, thread);
|
|
318
373
|
if ((await readFile(path, 'utf8').catch(() => null)) !== id)
|
|
@@ -321,10 +376,6 @@ async function renew(io, app, thread, id) {
|
|
|
321
376
|
await utimes(path, now, now).catch(() => { });
|
|
322
377
|
return true;
|
|
323
378
|
}
|
|
324
|
-
/**
|
|
325
|
-
* Codex: `codex queue`, which starts the turn itself: the codex on PATH, or the Codex app's own (0.13.0, codex-app.ts),
|
|
326
|
-
* found once, when the listener first wakes the thread.
|
|
327
|
-
*/
|
|
328
379
|
function byCodexQueue(io, thread) {
|
|
329
380
|
let program = null;
|
|
330
381
|
return async (message) => {
|
|
@@ -333,10 +384,6 @@ function byCodexQueue(io, thread) {
|
|
|
333
384
|
return queued.code === 0;
|
|
334
385
|
};
|
|
335
386
|
}
|
|
336
|
-
/**
|
|
337
|
-
* OpenCode and Pi: the plugin (Pi: the extension) that started this listener reads the wake on stdout and starts the
|
|
338
|
-
* turn, then says on stdin whether it did. stdin closing means the plugin (the app) is gone: `gone` stops the listener.
|
|
339
|
-
*/
|
|
340
387
|
function byPlugin(io, gone) {
|
|
341
388
|
const queue = [];
|
|
342
389
|
let waiting = null;
|
|
@@ -385,12 +432,32 @@ function byPlugin(io, gone) {
|
|
|
385
432
|
}
|
|
386
433
|
};
|
|
387
434
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
435
|
+
async function handedHere(io, options, requests) {
|
|
436
|
+
const { origin, app, thread } = options;
|
|
437
|
+
const ready = requests.filter((heard) => heard.ready && delegatedOf(heard) && !options.elsewhere(heard));
|
|
438
|
+
if (ready.length === 0)
|
|
439
|
+
return [];
|
|
440
|
+
const sessions = await candidates(io, app);
|
|
441
|
+
const mine = [];
|
|
442
|
+
for (const heard of ready) {
|
|
443
|
+
const moment = momentOf(heard);
|
|
444
|
+
if (await wasHanded(io, origin, heard.requestId, moment))
|
|
445
|
+
continue;
|
|
446
|
+
if (chooseThread(heard, sessions, { home: io.home }) !== thread)
|
|
447
|
+
continue;
|
|
448
|
+
if (!(await claimHanded(io, origin, heard.requestId, moment)))
|
|
449
|
+
continue;
|
|
450
|
+
mine.push({
|
|
451
|
+
heard,
|
|
452
|
+
card: handedCard(heard, delegatedOf(heard)),
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
return mine;
|
|
456
|
+
}
|
|
457
|
+
async function giveBack(io, origin, list) {
|
|
458
|
+
for (const { heard } of list)
|
|
459
|
+
await releaseHanded(io, origin, heard.requestId, momentOf(heard)).catch(() => { });
|
|
460
|
+
}
|
|
394
461
|
export async function listen(io, options) {
|
|
395
462
|
const { origin, app, thread } = options;
|
|
396
463
|
const lease = await takeLease(io, app, thread);
|
|
@@ -403,13 +470,16 @@ export async function listen(io, options) {
|
|
|
403
470
|
io.signal.addEventListener('abort', stop, { once: true });
|
|
404
471
|
const signal = controller.signal;
|
|
405
472
|
const deliver = app === 'codex' ? byCodexQueue(io, thread) : byPlugin(io, stop);
|
|
473
|
+
const takes = app !== 'codex';
|
|
474
|
+
const elsewhere = takes ? await elsewhereFor(io, origin, app) : () => false;
|
|
406
475
|
try {
|
|
407
476
|
const started = io.now();
|
|
408
477
|
let since;
|
|
409
478
|
let failures = 0;
|
|
410
479
|
while (io.now() - started < LISTEN_MS && !signal.aborted) {
|
|
411
|
-
const
|
|
412
|
-
|
|
480
|
+
const record = (await load(io, app)).threads[thread];
|
|
481
|
+
const cards = record?.cards ?? {};
|
|
482
|
+
if (!record || (Object.keys(cards).length === 0 && !(takes && mayBeHanded(record, io.now()))))
|
|
413
483
|
return 0;
|
|
414
484
|
if (!(await renew(io, app, thread, lease)))
|
|
415
485
|
return 0;
|
|
@@ -431,7 +501,6 @@ export async function listen(io, options) {
|
|
|
431
501
|
catch (error) {
|
|
432
502
|
if (error instanceof SignInNeeded || signal.aborted)
|
|
433
503
|
return 0;
|
|
434
|
-
// Renewed first: no pause here outlasts the lease.
|
|
435
504
|
if (!(await renew(io, app, thread, lease)))
|
|
436
505
|
return 0;
|
|
437
506
|
await io.sleep(BACKOFF_MS[Math.min(failures++, BACKOFF_MS.length - 1)], signal);
|
|
@@ -444,28 +513,34 @@ export async function listen(io, options) {
|
|
|
444
513
|
const done = [];
|
|
445
514
|
for (const heard of requests) {
|
|
446
515
|
const card = cards[heard.requestId];
|
|
447
|
-
if (!card || !heard.ready)
|
|
516
|
+
if (!card || !heard.ready || delegatedOf(heard))
|
|
448
517
|
continue;
|
|
449
|
-
// Handed over already (the person wrote in the app since): nothing to wake it for.
|
|
450
518
|
if (await wasHanded(io, origin, heard.requestId, momentOf(heard)))
|
|
451
519
|
done.push(heard.requestId);
|
|
452
520
|
else
|
|
453
521
|
ready.push({ heard, card });
|
|
454
522
|
}
|
|
523
|
+
const gone = () => change(io, app, (state) => {
|
|
524
|
+
delete state.threads[thread];
|
|
525
|
+
});
|
|
455
526
|
const message = wakeMessage(ready);
|
|
456
527
|
if (message) {
|
|
457
|
-
// The app doesn't know the thread (archived, gone), or can't be reached from here: the hooks hand answers over.
|
|
458
528
|
if (!(await deliver(message, ready.map(({ heard }) => heard.requestId)))) {
|
|
459
|
-
await
|
|
460
|
-
delete state.threads[thread];
|
|
461
|
-
});
|
|
529
|
+
await gone();
|
|
462
530
|
return 0;
|
|
463
531
|
}
|
|
464
532
|
await markHanded(io, origin, ready.map(({ heard }) => ({ requestId: heard.requestId, moment: momentOf(heard) })));
|
|
465
533
|
}
|
|
534
|
+
const handed = takes ? await handedHere(io, { origin, app, thread, elsewhere }, requests) : [];
|
|
535
|
+
const asked = handedMessage(handed.map(({ card }) => card));
|
|
536
|
+
if (asked &&
|
|
537
|
+
!(await deliver(asked, handed.map(({ heard }) => heard.requestId)))) {
|
|
538
|
+
await giveBack(io, origin, handed);
|
|
539
|
+
await gone();
|
|
540
|
+
return 0;
|
|
541
|
+
}
|
|
466
542
|
for (const { heard } of ready)
|
|
467
543
|
done.push(heard.requestId);
|
|
468
|
-
// Closed cards nobody needs telling about go too.
|
|
469
544
|
for (const heard of requests)
|
|
470
545
|
if (cards[heard.requestId] && isClosed(heard) && !done.includes(heard.requestId))
|
|
471
546
|
done.push(heard.requestId);
|
|
@@ -483,8 +558,101 @@ export async function listen(io, options) {
|
|
|
483
558
|
}
|
|
484
559
|
finally {
|
|
485
560
|
io.signal.removeEventListener('abort', stop);
|
|
486
|
-
// Only its own: a listener that took over keeps the thread.
|
|
487
561
|
if ((await readFile(leasePath(io, app, thread), 'utf8').catch(() => null)) === lease)
|
|
488
562
|
await rm(leasePath(io, app, thread), { force: true }).catch(() => { });
|
|
489
563
|
}
|
|
490
564
|
}
|
|
565
|
+
export async function listenHanded(io, options) {
|
|
566
|
+
const { origin, app } = options;
|
|
567
|
+
if (app !== 'codex')
|
|
568
|
+
return 0;
|
|
569
|
+
const lease = await takeLease(io, app, HANDED);
|
|
570
|
+
if (!lease)
|
|
571
|
+
return 0;
|
|
572
|
+
const controller = new AbortController();
|
|
573
|
+
const stop = () => controller.abort();
|
|
574
|
+
if (io.signal.aborted)
|
|
575
|
+
stop();
|
|
576
|
+
io.signal.addEventListener('abort', stop, { once: true });
|
|
577
|
+
const signal = controller.signal;
|
|
578
|
+
let program = null;
|
|
579
|
+
const queue = async (thread, message) => {
|
|
580
|
+
program ??= findCodex(io).then((found) => found?.command ?? 'codex');
|
|
581
|
+
const queued = await io.run(await program, ['queue', '--thread', thread, '--message', message], 30_000);
|
|
582
|
+
return queued.code === 0;
|
|
583
|
+
};
|
|
584
|
+
const elsewhere = await elsewhereFor(io, origin, app);
|
|
585
|
+
try {
|
|
586
|
+
const started = io.now();
|
|
587
|
+
let since;
|
|
588
|
+
let failures = 0;
|
|
589
|
+
while (io.now() - started < LISTEN_MS && !signal.aborted) {
|
|
590
|
+
const now = io.now();
|
|
591
|
+
if (!Object.values((await load(io, app)).threads).some((each) => mayBeHanded(each, now)))
|
|
592
|
+
return 0;
|
|
593
|
+
if (!(await renew(io, app, HANDED, lease)))
|
|
594
|
+
return 0;
|
|
595
|
+
const query = new URLSearchParams({
|
|
596
|
+
source: app,
|
|
597
|
+
limit: '20',
|
|
598
|
+
wait: String(LONG_POLL_SECONDS),
|
|
599
|
+
});
|
|
600
|
+
if (since)
|
|
601
|
+
query.set('since', since);
|
|
602
|
+
let requests;
|
|
603
|
+
try {
|
|
604
|
+
const { status, body } = await getJson(io, origin, `/mcp/cli/answers?${query}`, (LONG_POLL_SECONDS + 20) * 1000, signal, { app });
|
|
605
|
+
if (status !== 200)
|
|
606
|
+
throw new Unavailable(`Pending You answered ${status}.`);
|
|
607
|
+
requests = Array.isArray(body.requests) ? body.requests : [];
|
|
608
|
+
failures = 0;
|
|
609
|
+
}
|
|
610
|
+
catch (error) {
|
|
611
|
+
if (error instanceof SignInNeeded || signal.aborted)
|
|
612
|
+
return 0;
|
|
613
|
+
if (!(await renew(io, app, HANDED, lease)))
|
|
614
|
+
return 0;
|
|
615
|
+
await io.sleep(BACKOFF_MS[Math.min(failures++, BACKOFF_MS.length - 1)], signal);
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
for (const heard of requests)
|
|
619
|
+
if (!since || heard.changedAt > since)
|
|
620
|
+
since = heard.changedAt;
|
|
621
|
+
const sessions = requests.some((heard) => delegatedOf(heard)) ? await candidates(io, app) : [];
|
|
622
|
+
const byThread = new Map();
|
|
623
|
+
for (const heard of requests) {
|
|
624
|
+
const delegated = delegatedOf(heard);
|
|
625
|
+
if (!delegated || !heard.ready || elsewhere(heard))
|
|
626
|
+
continue;
|
|
627
|
+
const moment = momentOf(heard);
|
|
628
|
+
if (await wasHanded(io, origin, heard.requestId, moment))
|
|
629
|
+
continue;
|
|
630
|
+
const thread = chooseThread(heard, sessions, { home: io.home });
|
|
631
|
+
if (!thread || !(await claimHanded(io, origin, heard.requestId, moment)))
|
|
632
|
+
continue;
|
|
633
|
+
byThread.set(thread, [
|
|
634
|
+
...(byThread.get(thread) ?? []),
|
|
635
|
+
{ heard, card: handedCard(heard, delegated) },
|
|
636
|
+
]);
|
|
637
|
+
}
|
|
638
|
+
for (const [thread, list] of byThread) {
|
|
639
|
+
const message = handedMessage(list.map(({ card }) => card));
|
|
640
|
+
if (message && (await queue(thread, message)))
|
|
641
|
+
continue;
|
|
642
|
+
await giveBack(io, origin, list);
|
|
643
|
+
await change(io, app, (state) => {
|
|
644
|
+
delete state.threads[thread];
|
|
645
|
+
});
|
|
646
|
+
since = undefined;
|
|
647
|
+
}
|
|
648
|
+
if (io.now() - started > PATIENT_AFTER_MS)
|
|
649
|
+
await io.sleep(PATIENT_PAUSE_MS, signal);
|
|
650
|
+
}
|
|
651
|
+
return 0;
|
|
652
|
+
}
|
|
653
|
+
finally {
|
|
654
|
+
io.signal.removeEventListener('abort', stop);
|
|
655
|
+
if ((await readFile(leasePath(io, app, HANDED), 'utf8').catch(() => null)) === lease)
|
|
656
|
+
await rm(leasePath(io, app, HANDED), { force: true }).catch(() => { });
|
|
657
|
+
}
|
|
658
|
+
}
|