agentp 1.12.1 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,7 +59,8 @@ agentp [options] [url]
59
59
  Options:
60
60
 
61
61
  - `--qa`: print the original prompt and answer with labels (useful when used as a filter)
62
- - `--defer`: deferred execution — submit prompt, get immediate ticket, retrieve result later
62
+ - `--defer [N]`: deferred execution — submit a prompt and get a ticket immediately (or wait up to N seconds for the answer and print it if it arrives); pipe the ticket back to retrieve the result later
63
+ - `--onlineTicket`: print deferred tickets on a single line (default: pretty-printed JSON)
63
64
  - `--tg`: forward the answer to Telegram via tgagentp gateway (error if unreachable)
64
65
  - `--no-tg`: do not forward to Telegram
65
66
  - `--flush`: flush tgagentp's recorded buffer without prepending it to output
@@ -72,9 +73,17 @@ Options:
72
73
  By default, `--qa` auto-detects tgagentp (silently degrades if unavailable); standalone mode implies `--no-tg`.
73
74
  With `--tg`, errors if tgagentp is unavailable.
74
75
 
76
+ > **Telegram notification from deferred jobs:** Use `--defer --tg` (or
77
+ > `--defer N --tg`) to forward the answer to Telegram automatically when the
78
+ > background job completes. The detached child process runs the full `--tg`
79
+ > flow, including gateway detection and notification. If the gateway is
80
+ > unreachable at completion time, a warning is printed on stderr; the answer
81
+ > is still saved in the temp file and retrievable via ticket re-submission.
82
+
75
83
  Arguments:
76
84
 
77
85
  - `url`: OpenCode TUI server URL or port number (defaults to `4096`). Examples: `4096`, `http://localhost:4096`, `http://192.168.1.50:4096`
86
+ - Tip: `$(ocmux)` expands to the URL of the current project's server, so you can run `agentp $(ocmux)` from any shell.
78
87
 
79
88
  ## Examples
80
89
 
@@ -147,18 +156,83 @@ context for a new topic.
147
156
 
148
157
  Deferred execution with `--defer`:
149
158
 
150
- Submit a prompt and get an immediate ticket to retrieve the result later:
159
+ Submit a prompt and get a ticket to retrieve the result later:
151
160
 
152
161
  ```bash
153
- # Submit a prompt and get a deferred reference
162
+ # Submit a prompt and get a deferred ticket (optionally wait up to N seconds)
154
163
  DEFERRED=$(printf "Refactor the authentication module" | agentp --defer)
155
- # Output: <agentp-deferred>/tmp/agentp_deferred_20260803_1430_a1b2.tmp</agentp-deferred>
164
+ # Output: agentp_ticket {
165
+ # "ctime": "2026-08-03T14:30:00.000Z",
166
+ # "path": "/tmp/agentp_deferred_20260803_1430_a1b2.tmp",
167
+ # "server": "http://localhost:4096",
168
+ # "sessionId": "ses_abc123"
169
+ # }
170
+
171
+ # Or wait up to 60s for the answer; only get a ticket if it's not ready in time
172
+ DEFERRED=$(printf "Refactor the authentication module" | agentp --defer 60)
156
173
 
157
174
  # Continue working... retrieve the result when ready
158
175
  printf '%s\n' "$DEFERRED" | agentp --defer
159
176
  # Output: (the agent's response)
160
177
  ```
161
178
 
179
+ `--defer` takes an optional numeric timeout in seconds (default `0`). With a
180
+ timeout, the invocation blocks until the answer arrives or the timeout expires:
181
+ if the answer arrives in time it is printed immediately; otherwise a ticket is
182
+ returned and the agent keeps working in the background.
183
+
184
+ The ticket is `agentp_ticket` followed by a JSON object with these fields:
185
+
186
+ - `ctime` — creation timestamp (ISO 8601). Only used to compute `elapsed`.
187
+ - `path` — path to the temp file holding the result.
188
+ - `server` — OpenCode server URL used by the deferred job.
189
+ - `sessionId` — OpenCode session ID used by the deferred job.
190
+ - `elapsed` — seconds since `ctime`, included only when the ticket is re-printed (not on first print).
191
+ - `defer` — the timeout requested at submission, included only when it was > 0.
192
+
193
+ Tickets are printed as pretty-printed (multi-line) JSON for easier reading and
194
+ editing; pass `--onlineTicket` to print them on a single line instead:
195
+
196
+ ```bash
197
+ printf "Refactor the auth module" | agentp --defer --onlineTicket
198
+ # Output: agentp_ticket {"ctime":"...","path":"/tmp/agentp_deferred_....tmp"}
199
+ ```
200
+
201
+ Both formats are accepted when piping a ticket back to `agentp --defer`.
202
+
203
+ You can also append follow-up text after a not-yet-ready ticket to queue more
204
+ input into the original running task, similar to typing into the OpenCode TUI
205
+ while the agent is busy:
206
+
207
+ ```bash
208
+ cat <<'EOF' | agentp --defer
209
+ agentp_ticket {
210
+ "ctime": "2026-08-03T14:30:00.000Z",
211
+ "path": "/tmp/agentp_deferred_...tmp",
212
+ "server": "http://localhost:4096",
213
+ "sessionId": "ses_abc123"
214
+ }
215
+ Also make sure the migration is reversible.
216
+ EOF
217
+ ```
218
+
219
+ If the answer is already ready, the appended text is not sent to OpenCode; it is
220
+ printed after the returned answer so you can edit and re-submit it if needed. In
221
+ `--qa` output, it appears after the final ruler. If the answer is not ready, the
222
+ appended text is sent through OpenCode's async prompt endpoint for the ticket's
223
+ `server`/`sessionId`, stored with the ticket, and the ticket is re-printed with
224
+ updated `elapsed` without echoing the extra text in that interim output. When
225
+ the final answer is later retrieved with `--qa`, all stored follow-ups are
226
+ printed inside the prompt block under `📝` separators. Without `--qa`, stored
227
+ follow-ups are not printed. Older tickets without `server` and `sessionId` can
228
+ still retrieve results, but cannot queue follow-up text.
229
+
230
+ Piping a ticket back to `agentp --defer` ignores the `--defer` argument and uses
231
+ the ticket's own `defer` value as the timeout (default `0`):
232
+
233
+ - If the answer is ready, it is returned and the temp file is removed.
234
+ - If not, the ticket is re-printed with the elapsed time updated.
235
+
162
236
  Works as a Vim/Neovim filter with deferred execution:
163
237
 
164
238
  ```vim
@@ -166,15 +240,18 @@ Works as a Vim/Neovim filter with deferred execution:
166
240
  :'<,'>!agentp --defer --qa
167
241
 
168
242
  " Later, retrieve the result
169
- :r !printf '%s\n' "<agentp-deferred>/tmp/agentp_deferred_...tmp</agentp-deferred>" | agentp --defer
243
+ :r !printf '%s\n' "$(cat <<'EOF'
244
+ agentp_ticket {"ctime":"2026-08-03T14:30:00.000Z","path":"/tmp/agentp_deferred_...tmp","server":"http://localhost:4096","sessionId":"ses_abc123"}
245
+ EOF
246
+ )" | agentp --defer
170
247
  ```
171
248
 
172
249
  The deferred workflow:
173
- 1. Submit prompt with `--defer` → get immediate ticket (`<agentp-deferred>...</agentp-deferred>`)
250
+ 1. Submit prompt with `--defer` → get a ticket (`agentp_ticket {...}`)
174
251
  2. Continue working (the agent processes in background)
175
252
  3. When ready, pipe the ticket back to `agentp --defer` to retrieve the result
176
- 4. If the agent is still processing, you get your input back (identity filter)
177
- 4. If complete, you get the agent's response and the temp file is cleaned up
253
+ 4. If the agent is still processing, you get the ticket back with the elapsed time updated
254
+ 5. If complete, you get the agent's response and the temp file is cleaned up
178
255
 
179
256
  Useful for long-running tasks where you don't want to block your editor.
180
257
 
@@ -204,6 +281,7 @@ Subcommands:
204
281
  - `--print-logs` passes `--print-logs` to `opencode serve`, which prints server logs to stderr in the server tmux pane.
205
282
  - **`kill [dir]`** — Kill the server found upward from `dir`. Removes its tmux window and state file.
206
283
  - **`resurrect [--print-logs] [dir]`** — Recover a dead/crashed server: reads `.ocmux.json`, kills old tmux window, removes state file, then creates a fresh server + TUI in the same directory. Works even if no tmux window exists (stale state file).
284
+ - **`switch`** — Interactive session picker: an interactive menu of all running servers (columns: dirname, status, url, full path). Arrow keys or `j`/`k` move the selection; `Enter`/`Space` switches to the selected server's tmux window (the menu stays open, so you can hop between servers); `q` or `Ctrl+C` exits. The currently active server (queried live from tmux on every redraw) is highlighted across the full line width. Prints the URL of the last selected server on exit. Requires a TTY.
207
285
  - **`list`** — List all running servers with their directories, URLs, and status.
208
286
 
209
287
  Options:
@@ -217,6 +295,7 @@ Notes:
217
295
 
218
296
  - If `<directory>` is not a valid path, `ocmux` tries to match it against the basenames of existing sessions (exact unique match).
219
297
  - When using `--git`/`--GIT`, `ocmux` refuses to create a new server above an existing `.ocmux.json` found while searching for the git root.
298
+ - In default mode, if no server is found, the primary error line is printed to stdout. This intentionally makes `agentp $(ocmux)` fail instead of silently falling back to `agentp`'s default `localhost:4096` server.
220
299
 
221
300
  ### Requirements
222
301
 
package/bin/agentp CHANGED
@@ -10,6 +10,7 @@ const child_process = require('child_process');
10
10
  const { version } = require('../package.json');
11
11
  const {
12
12
  sendToSession,
13
+ sendToSessionAsync,
13
14
  listSessions,
14
15
  createSession,
15
16
  getSession,
@@ -108,6 +109,216 @@ function generateTempFilename() {
108
109
  return path.join(os.tmpdir(), `agentp_deferred_${timestamp}_${hash}.tmp`);
109
110
  }
110
111
 
112
+ // Poll interval for --defer waiting (ms). Overridable for tests.
113
+ const DEFER_POLL_MS = 250;
114
+ const FOLLOWUP_ROW = '📝 —————————————————\n';
115
+
116
+ function stripFollowupRow(text) {
117
+ return text.startsWith(FOLLOWUP_ROW) ? text.slice(FOLLOWUP_ROW.length) : text;
118
+ }
119
+
120
+ // Parse an `agentp_ticket <json>` reference from the start of input text.
121
+ // The ticket may be wrapped in whitespace/newlines and may be followed by
122
+ // additional text, which is returned separately for queued follow-ups.
123
+ function parseDeferredTicketInput(text) {
124
+ const trimmed = String(text).trim();
125
+ if (!trimmed.startsWith('agentp_ticket')) return null;
126
+ const afterPrefix = trimmed.slice('agentp_ticket'.length).trimStart();
127
+ if (!afterPrefix.startsWith('{')) return null;
128
+ let depth = 0;
129
+ let inString = false;
130
+ let escaped = false;
131
+ let jsonEnd = -1;
132
+ for (let i = 0; i < afterPrefix.length; i++) {
133
+ const ch = afterPrefix[i];
134
+ if (inString) {
135
+ if (escaped) {
136
+ escaped = false;
137
+ } else if (ch === '\\') {
138
+ escaped = true;
139
+ } else if (ch === '"') {
140
+ inString = false;
141
+ }
142
+ continue;
143
+ }
144
+ if (ch === '"') {
145
+ inString = true;
146
+ } else if (ch === '{') {
147
+ depth++;
148
+ } else if (ch === '}') {
149
+ depth--;
150
+ if (depth === 0) {
151
+ jsonEnd = i + 1;
152
+ break;
153
+ }
154
+ }
155
+ }
156
+ if (jsonEnd === -1) return null;
157
+ let data;
158
+ try {
159
+ data = JSON.parse(afterPrefix.slice(0, jsonEnd));
160
+ } catch {
161
+ return null;
162
+ }
163
+ if (!data || typeof data.path !== 'string' || !data.path) return null;
164
+ const rest = afterPrefix.slice(jsonEnd);
165
+ const followupText = rest.trim() ? stripFollowupRow(rest.trimStart()) : '';
166
+ return {
167
+ ctime: typeof data.ctime === 'string' ? data.ctime : null,
168
+ path: data.path,
169
+ defer: typeof data.defer === 'number' && data.defer > 0 ? data.defer : 0,
170
+ server: typeof data.server === 'string' ? data.server : null,
171
+ sessionId: typeof data.sessionId === 'string' ? data.sessionId : null,
172
+ followupText,
173
+ };
174
+ }
175
+
176
+ function parseDeferredTicket(text) {
177
+ const parsed = parseDeferredTicketInput(text);
178
+ if (!parsed) return null;
179
+ const { followupText, ...ticket } = parsed;
180
+ return ticket;
181
+ }
182
+
183
+ // Format a deferred ticket for printing. `elapsed` is only included on
184
+ // re-prints; `defer` only when a timeout > 0 was requested. Pretty-printed
185
+ // JSON by default (easier to read/edit); pass `compact` for a single line.
186
+ function formatTicket(ticket, elapsed, compact) {
187
+ const fields = { ctime: ticket.ctime, path: ticket.path };
188
+ if (ticket.server) fields.server = ticket.server;
189
+ if (ticket.sessionId) fields.sessionId = ticket.sessionId;
190
+ if (elapsed !== undefined) fields.elapsed = elapsed;
191
+ if (ticket.defer > 0) fields.defer = ticket.defer;
192
+ return 'agentp_ticket ' + (compact ? JSON.stringify(fields) : JSON.stringify(fields, null, 2));
193
+ }
194
+
195
+ async function resolveTargetSession(serverBase, sessionName, newSession) {
196
+ let sessions;
197
+ try {
198
+ sessions = await listSessions(serverBase);
199
+ } catch (err) {
200
+ throw new Error(`Error connecting to server: ${err.message}`);
201
+ }
202
+
203
+ if (sessionName) {
204
+ const matches = sessions.filter(s => {
205
+ const title = s.title || s.slug || s.id;
206
+ return title.toLowerCase() === sessionName.toLowerCase();
207
+ });
208
+ if (matches.length === 0) {
209
+ const partial = sessions.filter(s => {
210
+ const title = s.title || s.slug || s.id;
211
+ return title.toLowerCase().includes(sessionName.toLowerCase());
212
+ });
213
+ if (partial.length === 1) return partial[0].id;
214
+ if (partial.length > 1) throw new Error(`Error: multiple sessions match "${sessionName}"`);
215
+ if (!newSession) throw new Error(`Error: no session found matching "${sessionName}"`);
216
+ try {
217
+ const session = await createSession(serverBase, sessionName);
218
+ await selectSession(serverBase, session.id);
219
+ return session.id;
220
+ } catch (err) {
221
+ throw new Error(`Error creating session: ${err.message}`);
222
+ }
223
+ }
224
+ return matches[0].id;
225
+ }
226
+
227
+ if (sessions && sessions.length > 0) {
228
+ const sorted = sessions
229
+ .filter(s => s.time && (s.time.updated || s.time.created))
230
+ .sort((a, b) => {
231
+ const aTime = (a.time && a.time.updated) || (a.time && a.time.created) || 0;
232
+ const bTime = (b.time && b.time.updated) || (b.time && b.time.created) || 0;
233
+ return bTime - aTime;
234
+ });
235
+ if (sorted.length > 0) return sorted[0].id;
236
+ }
237
+
238
+ try {
239
+ const session = await createSession(serverBase, 'agentp');
240
+ return session.id;
241
+ } catch (err) {
242
+ throw new Error(`Error creating session: ${err.message}`);
243
+ }
244
+ }
245
+
246
+ // Read the deferred output file. Returns the answer string, or null if the
247
+ // answer is not ready yet (file missing, still locked, or still empty).
248
+ function readDeferredResult(filePath) {
249
+ if (!fs.existsSync(filePath)) return null;
250
+ if (isFileLocked(filePath)) return null;
251
+ let content = '';
252
+ try {
253
+ content = fs.readFileSync(filePath, 'utf8');
254
+ } catch {
255
+ return null;
256
+ }
257
+ return content === '' ? null : content;
258
+ }
259
+
260
+ // Poll up to timeoutMs for a ready deferred result. Returns the answer or null.
261
+ async function waitForDeferredResult(filePath, timeoutMs) {
262
+ const deadline = Date.now() + timeoutMs;
263
+ for (;;) {
264
+ const result = readDeferredResult(filePath);
265
+ if (result !== null) return result;
266
+ const remaining = deadline - Date.now();
267
+ if (remaining <= 0) return null;
268
+ await sleepMs(Math.min(DEFER_POLL_MS, remaining));
269
+ }
270
+ }
271
+
272
+ function sleepMs(ms) {
273
+ return new Promise((resolve) => setTimeout(resolve, ms));
274
+ }
275
+
276
+ function appendUnusedFollowup(result, followupText) {
277
+ if (!followupText) return result;
278
+ return result + (result.endsWith('\n') ? '' : '\n') + followupText;
279
+ }
280
+
281
+ function queuedFollowupsPath(filePath) {
282
+ return filePath + '.followups';
283
+ }
284
+
285
+ function readQueuedFollowups(filePath) {
286
+ try {
287
+ const text = fs.readFileSync(queuedFollowupsPath(filePath), 'utf8');
288
+ const parsed = JSON.parse(text);
289
+ return Array.isArray(parsed) ? parsed.filter(item => typeof item === 'string' && item) : [];
290
+ } catch {
291
+ return [];
292
+ }
293
+ }
294
+
295
+ function appendQueuedFollowup(filePath, followupText) {
296
+ if (!followupText) return;
297
+ const followups = readQueuedFollowups(filePath);
298
+ followups.push(followupText);
299
+ fs.writeFileSync(queuedFollowupsPath(filePath), JSON.stringify(followups, null, 2));
300
+ }
301
+
302
+ function removeQueuedFollowups(filePath) {
303
+ try { fs.unlinkSync(queuedFollowupsPath(filePath)); } catch {}
304
+ }
305
+
306
+ function injectQueuedFollowups(result, followups) {
307
+ if (!followups.length) return result;
308
+ const addition = followups.map(text => FOLLOWUP_ROW + text + (text.endsWith('\n') ? '' : '\n')).join('');
309
+ const idx = result.indexOf(AGENT_ROW);
310
+ if (idx === -1) return appendUnusedFollowup(result, addition.trimEnd());
311
+ const before = result.slice(0, idx);
312
+ const after = result.slice(idx);
313
+ return before + (before.endsWith('\n') ? '' : '\n') + addition + after;
314
+ }
315
+
316
+ function formatDeferredResult(filePath, result, qaMode, unusedFollowupText) {
317
+ const followups = qaMode ? readQueuedFollowups(filePath) : [];
318
+ const output = qaMode ? injectQueuedFollowups(result, followups) : result;
319
+ return appendUnusedFollowup(output, unusedFollowupText);
320
+ }
321
+
111
322
  async function readAllStdin() {
112
323
  let text = '';
113
324
  const rl = readline.createInterface({ input: process.stdin });
@@ -127,10 +338,13 @@ async function main() {
127
338
  let qaMode = false;
128
339
  let flushMode = false;
129
340
  let deferMode = false;
341
+ let deferSeconds = 0;
130
342
  let deferChildMode = false;
343
+ let onlineTicket = false;
131
344
  let promptFile = null;
132
345
  let outputFile = null;
133
346
  let sessionName = null;
347
+ let resolvedSessionId = null;
134
348
  let newSession = false;
135
349
  // tgMode: false = disabled, 'auto' = auto-detect, true = explicit --tg
136
350
  let tgMode = null;
@@ -148,7 +362,11 @@ async function main() {
148
362
  console.error('Options:');
149
363
  console.error(' --version Show version');
150
364
  console.error(' --qa Print the original prompt and answer with labels (useful as a filter)');
151
- console.error(' --defer Deferred execution: write prompt to temp file, return path immediately');
365
+ console.error(' --defer [N] Deferred execution: submit prompt, get a ticket immediately');
366
+ console.error(' (or wait up to N seconds for the answer and print it if it arrives)');
367
+ console.error(' Pipe the ticket back to agentp --defer to retrieve the result later');
368
+ console.error(' --onlineTicket');
369
+ console.error(' Print deferred tickets on a single line (default: pretty-printed JSON)');
152
370
  console.error(' --tg Forward the answer to Telegram via agentp gateway (error if unavailable)');
153
371
  console.error(' --no-tg Do not forward to Telegram');
154
372
  console.error(' --flush Flush tgagentp\'s recorded buffer without prepending it to output');
@@ -174,7 +392,7 @@ async function main() {
174
392
  console.error('Arguments:');
175
393
  console.error(' url OpenCode server URL or port number (default: 4096)');
176
394
  console.error(' Examples: 4096, http://localhost:4096, http://192.168.1.50:4096');
177
- console.error(' Use `ocmux` (with no args) to get the URL of the current project\'s server.');
395
+ console.error(' Tip: $(ocmux) expands to the URL of the current project\'s server.');
178
396
  console.error('');
179
397
  console.error('See also: tgagentp -- Telegram bridge (tgagentp --help), ocmux -- manage OpenCode tmux sessions (ocmux --help)');
180
398
  console.error('');
@@ -194,8 +412,17 @@ async function main() {
194
412
  qaMode = true;
195
413
  } else if (args[i] === '--defer') {
196
414
  deferMode = true;
415
+ const next = args[i + 1];
416
+ if (next !== undefined && /^\d+$/.test(next)) {
417
+ deferSeconds = parseInt(next, 10);
418
+ i++;
419
+ } else {
420
+ deferSeconds = 0;
421
+ }
197
422
  } else if (args[i] === '--defer-child') {
198
423
  deferChildMode = true;
424
+ } else if (args[i] === '--onlineTicket') {
425
+ onlineTicket = true;
199
426
  } else if (args[i] === '--prompt-file') {
200
427
  i++;
201
428
  promptFile = args[i];
@@ -211,6 +438,13 @@ async function main() {
211
438
  console.error('Error: --session requires a session name');
212
439
  process.exit(1);
213
440
  }
441
+ } else if (args[i] === '--resolved-session-id') {
442
+ i++;
443
+ resolvedSessionId = args[i];
444
+ if (!resolvedSessionId) {
445
+ console.error('Error: --resolved-session-id requires a session id');
446
+ process.exit(1);
447
+ }
214
448
  } else if (args[i] === '--new') {
215
449
  newSession = true;
216
450
  } else if (args[i] === '--getLast') {
@@ -354,85 +588,148 @@ async function main() {
354
588
 
355
589
  // Handle --defer mode
356
590
  if (deferMode) {
357
- // Check if stdin is a deferred reference
358
- const deferredMatch = promptText.match(/<agentp-deferred>(.*?)<\/agentp-deferred>/);
359
-
360
- if (deferredMatch) {
361
- // This is a deferred reference - retrieve the result
362
- const deferredPath = deferredMatch[1].trim();
363
-
591
+ // Check if stdin is a deferred ticket
592
+ const ticket = parseDeferredTicketInput(promptText);
593
+
594
+ if (ticket) {
595
+ // This is a ticket — retrieve the result. The --defer argument is
596
+ // ignored; the ticket's own "defer" timeout is used instead (default 0).
597
+ const deferredPath = ticket.path;
598
+
364
599
  if (!fs.existsSync(deferredPath)) {
365
- // File doesn't exist - error
366
600
  process.stdout.write(`Error: deferred file not found: ${deferredPath}\n`);
367
601
  process.exit(1);
368
602
  }
369
-
370
- // Check if file is locked (still processing) or empty (not ready yet)
371
- const isLocked = isFileLocked(deferredPath);
372
- let content = '';
373
- try {
374
- content = fs.readFileSync(deferredPath, 'utf8');
375
- } catch {
376
- content = '';
377
- }
378
-
379
- if (isLocked || content === '') {
380
- // File is locked (still processing) or empty (not ready yet) - identity filter
381
- process.stdout.write(promptText);
603
+
604
+ let result = ticket.followupText
605
+ ? readDeferredResult(deferredPath)
606
+ : await waitForDeferredResult(deferredPath, ticket.defer * 1000);
607
+
608
+ if (result !== null) {
609
+ // Answer available — return it and remove the temp file
610
+ try {
611
+ fs.unlinkSync(deferredPath);
612
+ } catch (err) {
613
+ process.stdout.write(`Error reading deferred file: ${err.message}\n`);
614
+ process.exit(1);
615
+ }
616
+ try { fs.unlinkSync(deferredPath + '.lock'); } catch {}
617
+ process.stdout.write(formatDeferredResult(deferredPath, result, qaMode, ticket.followupText));
618
+ removeQueuedFollowups(deferredPath);
382
619
  process.exit(0);
383
620
  }
384
-
385
- // File exists, not locked, and has content - read and delete
386
- try {
387
- fs.unlinkSync(deferredPath);
388
- process.stdout.write(content);
389
- process.exit(0);
390
- } catch (err) {
391
- process.stdout.write(`Error reading deferred file: ${err.message}\n`);
392
- process.exit(1);
621
+
622
+ if (ticket.followupText) {
623
+ if (!ticket.server || !ticket.sessionId) {
624
+ process.stdout.write('Error: deferred ticket does not include server/sessionId; cannot queue follow-up text. Re-submit only the ticket to check the result.\n');
625
+ process.exit(1);
626
+ }
627
+ try {
628
+ await sendToSessionAsync(ticket.server, ticket.sessionId, ticket.followupText);
629
+ appendQueuedFollowup(deferredPath, ticket.followupText);
630
+ } catch (err) {
631
+ process.stdout.write(`Error queueing follow-up prompt: ${err.message}\n`);
632
+ process.exit(1);
633
+ }
393
634
  }
394
- } else {
395
- // This is a regular prompt - create deferred execution
396
- const tempPath = generateTempFilename();
397
- const promptTempPath = tempPath + '.prompt';
398
-
399
- // Write prompt to temp file for child process
400
- fs.writeFileSync(promptTempPath, promptText);
401
-
402
- // Create and lock the output file
403
- fs.writeFileSync(tempPath, '');
404
- lockFile(tempPath);
405
-
406
- // Output the deferred reference
407
- process.stdout.write(`<agentp-deferred>${tempPath}</agentp-deferred>\n`);
408
-
409
- // Spawn detached child process
410
- const childArgs = [
411
- __filename,
412
- '--defer-child',
413
- '--prompt-file', promptTempPath,
414
- '--output-file', tempPath,
415
- ];
416
-
417
- // Pass through other flags
418
- if (qaMode) childArgs.push('--qa');
419
- if (tgMode === true) childArgs.push('--tg');
420
- if (tgMode === false) childArgs.push('--no-tg');
421
- if (flushMode) childArgs.push('--flush');
422
- if (sessionName) {
423
- childArgs.push('--session', sessionName);
424
- if (newSession) childArgs.push('--new');
635
+
636
+ if (result === null && ticket.defer > 0) {
637
+ result = await waitForDeferredResult(deferredPath, ticket.defer * 1000);
638
+ if (result !== null) {
639
+ try {
640
+ fs.unlinkSync(deferredPath);
641
+ } catch (err) {
642
+ process.stdout.write(`Error reading deferred file: ${err.message}\n`);
643
+ process.exit(1);
644
+ }
645
+ try { fs.unlinkSync(deferredPath + '.lock'); } catch {}
646
+ process.stdout.write(formatDeferredResult(deferredPath, result, qaMode, ''));
647
+ removeQueuedFollowups(deferredPath);
648
+ process.exit(0);
649
+ }
425
650
  }
426
- childArgs.push(serverBase);
427
-
428
- const child = child_process.spawn(process.execPath, childArgs, {
429
- detached: true,
430
- stdio: 'ignore',
431
- });
432
- child.unref();
433
-
651
+
652
+ // Answer not available yet — re-print the ticket with updated elapsed
653
+ const elapsed = ticket.ctime
654
+ ? Math.max(0, Math.floor((Date.now() - Date.parse(ticket.ctime)) / 1000))
655
+ : 0;
656
+ process.stdout.write(formatTicket(ticket, elapsed, onlineTicket) + '\n');
434
657
  process.exit(0);
435
658
  }
659
+
660
+ // This is a regular prompt — create deferred execution
661
+ const tempPath = generateTempFilename();
662
+ const promptTempPath = tempPath + '.prompt';
663
+
664
+ let targetSessionId;
665
+ try {
666
+ targetSessionId = await resolveTargetSession(serverBase, sessionName, newSession);
667
+ } catch (err) {
668
+ console.error(err.message);
669
+ process.exit(1);
670
+ }
671
+
672
+ // Write prompt to temp file for child process
673
+ fs.writeFileSync(promptTempPath, promptText);
674
+
675
+ // Create and lock the output file
676
+ fs.writeFileSync(tempPath, '');
677
+ lockFile(tempPath);
678
+
679
+ const newTicket = {
680
+ ctime: new Date().toISOString(),
681
+ path: tempPath,
682
+ defer: deferSeconds,
683
+ server: serverBase,
684
+ sessionId: targetSessionId,
685
+ };
686
+
687
+ // Spawn detached child process
688
+ const childArgs = [
689
+ __filename,
690
+ '--defer-child',
691
+ '--prompt-file', promptTempPath,
692
+ '--output-file', tempPath,
693
+ ];
694
+
695
+ // Pass through other flags
696
+ if (qaMode) childArgs.push('--qa');
697
+ if (tgMode === true) childArgs.push('--tg');
698
+ if (tgMode === false) childArgs.push('--no-tg');
699
+ if (flushMode) childArgs.push('--flush');
700
+ if (sessionName) {
701
+ childArgs.push('--session', sessionName);
702
+ if (newSession) childArgs.push('--new');
703
+ }
704
+ childArgs.push('--resolved-session-id', targetSessionId);
705
+ childArgs.push(serverBase);
706
+
707
+ const child = child_process.spawn(process.execPath, childArgs, {
708
+ detached: true,
709
+ stdio: 'ignore',
710
+ });
711
+ child.unref();
712
+
713
+ // If a timeout was given, block until the answer arrives or the timeout
714
+ // expires. Return the response when it arrives; otherwise print a ticket.
715
+ if (deferSeconds > 0) {
716
+ const result = await waitForDeferredResult(tempPath, deferSeconds * 1000);
717
+ if (result !== null) {
718
+ try {
719
+ fs.unlinkSync(tempPath);
720
+ } catch (err) {
721
+ process.stdout.write(`Error reading deferred file: ${err.message}\n`);
722
+ process.exit(1);
723
+ }
724
+ try { fs.unlinkSync(tempPath + '.lock'); } catch {}
725
+ process.stdout.write(result);
726
+ process.exit(0);
727
+ }
728
+ }
729
+
730
+ // Output the deferred ticket
731
+ process.stdout.write(formatTicket(newTicket, undefined, onlineTicket) + '\n');
732
+ process.exit(0);
436
733
  }
437
734
 
438
735
  // Handle --defer-child mode (child process)
@@ -450,67 +747,9 @@ async function main() {
450
747
  }
451
748
 
452
749
  // Wrap main processing in try-catch for deferChildMode to write errors to output file
750
+ let tgError = null;
453
751
  async function runMain() {
454
- // Find most recent session or create one
455
- let sessions;
456
- try {
457
- sessions = await listSessions(serverBase);
458
- } catch (err) {
459
- throw new Error(`Error connecting to server: ${err.message}`);
460
- }
461
-
462
- let sessionId = null;
463
-
464
- if (sessionName) {
465
- // Find session by name
466
- const matches = sessions.filter(s => {
467
- const title = s.title || s.slug || s.id;
468
- return title.toLowerCase() === sessionName.toLowerCase();
469
- });
470
- if (matches.length === 0) {
471
- // Try partial match
472
- const partial = sessions.filter(s => {
473
- const title = s.title || s.slug || s.id;
474
- return title.toLowerCase().includes(sessionName.toLowerCase());
475
- });
476
- if (partial.length === 1) {
477
- sessionId = partial[0].id;
478
- } else if (partial.length > 1) {
479
- throw new Error(`Error: multiple sessions match "${sessionName}"`);
480
- } else if (newSession) {
481
- // Create new session
482
- try {
483
- const session = await createSession(serverBase, sessionName);
484
- sessionId = session.id;
485
- await selectSession(serverBase, sessionId);
486
- } catch (err) {
487
- throw new Error(`Error creating session: ${err.message}`);
488
- }
489
- } else {
490
- throw new Error(`Error: no session found matching "${sessionName}"`);
491
- }
492
- } else {
493
- sessionId = matches[0].id;
494
- }
495
- } else if (sessions && sessions.length > 0) {
496
- const sorted = sessions
497
- .filter(s => s.time && (s.time.updated || s.time.created))
498
- .sort((a, b) => {
499
- const aTime = (a.time && a.time.updated) || (a.time && a.time.created) || 0;
500
- const bTime = (b.time && b.time.updated) || (b.time && b.time.created) || 0;
501
- return bTime - aTime;
502
- });
503
- if (sorted.length > 0) sessionId = sorted[0].id;
504
- }
505
-
506
- if (!sessionId) {
507
- try {
508
- const session = await createSession(serverBase, 'agentp');
509
- sessionId = session.id;
510
- } catch (err) {
511
- throw new Error(`Error creating session: ${err.message}`);
512
- }
513
- }
752
+ const sessionId = resolvedSessionId || await resolveTargetSession(serverBase, sessionName, newSession);
514
753
 
515
754
  let answer;
516
755
  try {
@@ -529,7 +768,6 @@ async function main() {
529
768
 
530
769
  // Post-send: notify tgagentp, get recorded context back
531
770
  let buffered = [];
532
- let tgError = null;
533
771
  if (tgPort) {
534
772
  try {
535
773
  buffered = await notifyAgentpGateway(tgPort, serverBase, tgMessage);
@@ -566,7 +804,7 @@ async function main() {
566
804
  fs.writeFileSync(outputFile, errMsg);
567
805
  unlockFile(outputFile);
568
806
  } else {
569
- process.stdout.write(errMsg);
807
+ console.error(err.message);
570
808
  }
571
809
  process.exit(1);
572
810
  }
@@ -605,5 +843,4 @@ if (require.main === module) {
605
843
  });
606
844
  }
607
845
 
608
- module.exports = { main, notifyAgentpGateway, readAllStdin };
609
-
846
+ module.exports = { main, notifyAgentpGateway, readAllStdin, parseDeferredTicket, parseDeferredTicketInput, formatTicket, waitForDeferredResult };
package/bin/ocmux CHANGED
@@ -24,6 +24,7 @@ const {
24
24
  resurrectServer,
25
25
  startServer,
26
26
  getActiveServer,
27
+ activateServer,
27
28
  } = require('../lib/ocmux');
28
29
 
29
30
  // ---------------------------------------------------------------------------
@@ -182,6 +183,9 @@ function printHelp() {
182
183
  console.error(' Works even if no tmux window exists (stale state file).');
183
184
  console.error(' --print-logs Pass --print-logs to the new opencode serve.');
184
185
  console.error(' list List all running servers with their directories and URLs.');
186
+ console.error(' switch Interactively select a server to switch to.');
187
+ console.error(' j/k or arrows to move, Enter/Space selects (menu stays open),');
188
+ console.error(' q or Ctrl+C quits. Prints the URL of the last selected server on exit.');
185
189
  console.error('');
186
190
  console.error(' -- Treat the next argument as a directory even if it matches');
187
191
  console.error(' a subcommand name (e.g. "ocmux -- kill").');
@@ -253,6 +257,116 @@ function cmdSwitch(statefile, longMode) {
253
257
  outputUrl(url, serverDir, longMode);
254
258
  }
255
259
 
260
+ // ---------------------------------------------------------------------------
261
+ // Subcommand: switch — interactive session selection
262
+ // ---------------------------------------------------------------------------
263
+
264
+ function cmdSwitchMenu(longMode) {
265
+ const rows = listServers();
266
+ if (rows.length === 0) {
267
+ console.error('No opencode servers running.');
268
+ process.exit(1);
269
+ }
270
+ if (!process.stdin.isTTY) {
271
+ console.error('Error: "switch" is interactive and requires a TTY.');
272
+ console.error('Use "ocmux list" to list servers, or run "ocmux <dir>" to switch directly.');
273
+ process.exit(1);
274
+ }
275
+
276
+ const cols = process.stderr.columns || 80;
277
+ const nameW = Math.max(...rows.map(r => path.basename(r.dir).length), 4);
278
+ const stsW = Math.max(...rows.map(r => r.status.length), 6);
279
+ const urlW = Math.max(...rows.map(r => r.url.length), 3);
280
+ const dirW = Math.max(...rows.map(r => r.dir.length), 9);
281
+ const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
282
+ const lineWidth = Math.max(cols, nameW + 2 + stsW + 2 + urlW + 2 + dirW);
283
+
284
+ let cursor = 0;
285
+ let activeRow = 0;
286
+ let lastUrl = null;
287
+ let lastDir = null;
288
+ let exited = false;
289
+
290
+ // Query tmux for the currently active window and map it to a row. Called at
291
+ // init and on every draw, so the highlight always reflects tmux's real state.
292
+ const refreshActive = () => {
293
+ const idx = activeWindowIndex();
294
+ if (idx == null) return;
295
+ for (let i = 0; i < rows.length; i++) {
296
+ if (rows[i].index === idx) { activeRow = i; return; }
297
+ }
298
+ };
299
+
300
+ const cleanup = () => {
301
+ process.stderr.write('\x1b[?25h');
302
+ process.stderr.write('\x1b[?1049l');
303
+ try { process.stdin.setRawMode(false); } catch {}
304
+ };
305
+
306
+ const finish = () => {
307
+ if (exited) return;
308
+ exited = true;
309
+ cleanup();
310
+ process.stdin.pause();
311
+ if (lastUrl) outputUrl(lastUrl, lastDir, longMode);
312
+ process.exit(0);
313
+ };
314
+
315
+ const draw = () => {
316
+ refreshActive();
317
+ let out = '\x1b[2J\x1b[H';
318
+ out += 'ocmux switch — j/k or arrows: move, Enter/Space: select (keep switching), q/Ctrl+C: quit\n\n';
319
+ for (let i = 0; i < rows.length; i++) {
320
+ const row = rows[i];
321
+ const marker = i === cursor ? '▶ ' : ' ';
322
+ const line = `${marker}${pad(path.basename(row.dir), nameW)} ${pad(row.status, stsW)} ${pad(row.url, urlW)} ${pad(row.dir, dirW)}`;
323
+ if (i === activeRow) {
324
+ out += '\x1b[7m' + pad(line, lineWidth) + '\x1b[0m\n';
325
+ } else {
326
+ out += line + '\n';
327
+ }
328
+ }
329
+ process.stderr.write(out);
330
+ };
331
+
332
+ const activate = (i) => {
333
+ const row = rows[i];
334
+ try {
335
+ activateServer(row.dir, row.index, row.url);
336
+ lastUrl = row.url;
337
+ lastDir = row.dir;
338
+ } catch (err) {
339
+ process.stderr.write(`\nError: ${err.message}\n`);
340
+ }
341
+ draw();
342
+ };
343
+
344
+ process.stdin.on('keypress', (str, key) => {
345
+ if (key.ctrl && key.name === 'c') return finish();
346
+ if (key.name === 'q') return finish();
347
+ if (key.name === 'up' || str === 'k') {
348
+ cursor = (cursor + rows.length - 1) % rows.length;
349
+ draw();
350
+ } else if (key.name === 'down' || str === 'j') {
351
+ cursor = (cursor + 1) % rows.length;
352
+ draw();
353
+ } else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
354
+ activate(cursor);
355
+ }
356
+ });
357
+
358
+ process.on('SIGINT', finish);
359
+ process.on('SIGTERM', finish);
360
+
361
+ readline.emitKeypressEvents(process.stdin);
362
+ process.stdin.setRawMode(true);
363
+ process.stdin.resume();
364
+ process.stderr.write('\x1b[?1049h\x1b[?25l');
365
+ refreshActive();
366
+ cursor = activeRow;
367
+ draw();
368
+ }
369
+
256
370
  // ---------------------------------------------------------------------------
257
371
  // Subcommand: list
258
372
  // ---------------------------------------------------------------------------
@@ -506,7 +620,7 @@ function main() {
506
620
  process.exit(1);
507
621
  }
508
622
  targetDir = resolveDir(arg);
509
- } else if (arg === 'serve' || arg === 'new' || arg === 'kill' || arg === 'list' || arg === 'resurrect') {
623
+ } else if (arg === 'serve' || arg === 'new' || arg === 'kill' || arg === 'list' || arg === 'resurrect' || arg === 'switch') {
510
624
  if (subcommand) {
511
625
  console.error(`Error: multiple subcommands specified: '${subcommand}' and '${arg}'`);
512
626
  process.exit(1);
@@ -566,6 +680,21 @@ function main() {
566
680
  process.exit(1);
567
681
  }
568
682
  cmdList();
683
+ } else if (subcommand === 'switch') {
684
+ if (gitMode) {
685
+ console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
686
+ process.exit(1);
687
+ }
688
+ if (printLogs) {
689
+ console.error("Error: '--print-logs' is only valid with 'serve'.");
690
+ process.exit(1);
691
+ }
692
+ if (targetDir !== process.cwd()) {
693
+ console.error("Error: 'switch' does not accept a directory path.");
694
+ process.exit(1);
695
+ }
696
+ ensureSession();
697
+ cmdSwitchMenu(longMode);
569
698
  } else if (subcommand === 'kill') {
570
699
  if (gitMode) {
571
700
  console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
@@ -606,7 +735,7 @@ function main() {
606
735
  // Default mode — find an existing server and switch to it
607
736
  let sf = findParentStatefile(targetDir);
608
737
  if (!sf) {
609
- console.error(`Error: no opencode server found in ${targetDir} or any of its parents.`);
738
+ console.log(`Error: no opencode server found in ${targetDir} or any of its parents.`);
610
739
  console.error("Run 'ocmux serve' to create a new server.");
611
740
  process.exit(1);
612
741
  }
@@ -628,4 +757,8 @@ function main() {
628
757
  }
629
758
  }
630
759
 
631
- main();
760
+ if (require.main === module) {
761
+ main();
762
+ }
763
+
764
+ module.exports = { main };
@@ -31,13 +31,25 @@ Reads stdin, sends to the most recent or named OpenCode session, streams answer
31
31
  **Options:**
32
32
 
33
33
  | Flag | Effect |
34
- |---|---|
34
+ |---|---|---|
35
35
  | `--qa` | Print prompt/answer with rulers; auto-detect tgagentp |
36
+ | `--defer [N]` | Deferred execution — submit a prompt and get a ticket immediately, or wait up to N seconds (default `0`) for the answer; piping the ticket back retrieves the result later |
37
+ | `--onlineTicket` | Print deferred tickets on a single line (default: pretty-printed JSON) |
36
38
  | `--tg` | Forward answer via tgagentp gateway (error if unavailable) |
37
39
  | `--no-tg` | Explicitly disable Telegram forwarding |
38
40
  | `--flush` | Flush recorded buffer without prepending |
39
41
  | `--getLast N` | Retrieve last N assistant answers from session history |
40
42
 
43
+ **Deferred tickets (`--defer`):**
44
+
45
+ Ticket format on stdout: `agentp_ticket` followed by a JSON object `{ ctime, path, server, sessionId, elapsed, defer }`, printed as pretty-printed (multi-line) JSON by default or on a single line with `--onlineTicket`. Both formats are accepted when piping a ticket back.
46
+
47
+ - First print omits `elapsed`; `defer` is included only when it was `> 0`.
48
+ - Re-prints (answer not ready yet) include `elapsed` computed from `ctime` (0 when `ctime` is missing/unparseable).
49
+ - A ticket piped back to `agentp --defer` ignores the CLI `--defer` value and uses the ticket's own `defer` (default 0): ready → print answer + delete temp file; not ready → re-print the ticket with updated `elapsed`; file missing → error, exit 1.
50
+ - If a ticket is followed by additional text and the answer is not ready, `agentp --defer` sends that text to the ticket's `server`/`sessionId` with `sendToSessionAsync()` (`POST /session/:id/prompt_async`), stores it in a ticket sidecar file, and re-prints the ticket without echoing the extra text. When the final answer is retrieved with `--qa`, stored follow-ups are injected into the prompt block under `📝` separators; without `--qa`, they are omitted from output. If the answer is ready, appended text is not sent and is printed after the returned answer. Older tickets without `server`/`sessionId` can retrieve results but cannot queue follow-up text.
51
+ - New run with `--defer N` (N > 0): wait up to N seconds polling the temp file; answer arrives in time → print + delete; else print the ticket and keep the background child alive.
52
+
41
53
  **Protocol:**
42
54
 
43
55
  1. Reads all stdin → string
@@ -63,9 +75,14 @@ Manages per-project OpenCode servers in a persistent `Opencode` tmux session.
63
75
  | `new [dir]` | Alias for `serve` (deprecated) |
64
76
  | `kill [dir]` | Kill server, remove tmux window + `.ocmux.json` |
65
77
  | `resurrect [dir]` | Recover dead server: kill old window, remove state file, create fresh server + TUI |
78
+ | `switch` | Interactive session picker (TTY required) |
66
79
  | `list [-l]` | List all running servers |
67
80
  | _(no arg)_ | Switch to existing server (searches upward for `.ocmux.json`) |
68
81
 
82
+ **`switch` behavior:** Renders an interactive menu of all running servers on an alt screen with columns `dirname | status | url | full path`. `j`/`k` or arrow keys move the cursor; `Enter`/`Space` activates the selected server (switches its tmux window) and keeps the menu open; `q`/`Ctrl+C` exits and prints the URL of the last selected server (if any). The row matching the tmux-active window (queried live from tmux via `activeWindowIndex()` on every redraw) is highlighted across the full line width, so the highlight tracks both local activations and external tmux window switches. Errors (exit 1) when no TTY or no servers. Rejects `--git`, `--GIT`, `--print-logs`, and directory arguments.
83
+
84
+ **Default no-server behavior:** When no `.ocmux.json` is found for the current/target directory or any parent, default mode prints the primary `Error: no opencode server found...` line to stdout and the `Run 'ocmux serve'...` hint to stderr. This makes command substitution (`agentp $(ocmux)`) fail safely instead of passing no argument and falling back to `agentp`'s default server.
85
+
69
86
  **Flags:** `--git`, `--GIT`, `--print-logs`, `-l`, `--version`
70
87
 
71
88
  **Window layout:**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentp",
3
- "version": "1.12.1",
3
+ "version": "1.13.0",
4
4
  "description": "Pipe prompts into a running OpenCode TUI session and stream the final answer",
5
5
  "bin": {
6
6
  "agentp": "bin/agentp",