agentp 0.12.0 → 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 +115 -0
- package/bin/agentp +509 -83
- package/bin/ocmux +136 -3
- package/bin/tgagentp +57 -18
- package/docs/specification.md +18 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,6 +22,8 @@ It is designed for prompt-driven workflows where you want to do things like:
|
|
|
22
22
|
|
|
23
23
|
These tools are built for my own daily workflow. They are heavily AI-assisted — including the tests — and I review things before shipping, but the real test is using them every day. Bugs happen; I value a working feature more than a flawless one. MIT license, no warranty. Issues, suggestions, and PRs are welcome.
|
|
24
24
|
|
|
25
|
+
**Stability note:** `agentp` and `ocmux` are stable and used daily. `tgagentp` is still in a highly experimental stage — expect breaking changes and occasional bugs.
|
|
26
|
+
|
|
25
27
|
## Install
|
|
26
28
|
|
|
27
29
|
From npm:
|
|
@@ -57,6 +59,8 @@ agentp [options] [url]
|
|
|
57
59
|
Options:
|
|
58
60
|
|
|
59
61
|
- `--qa`: print the original prompt and answer with labels (useful when used as a filter)
|
|
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)
|
|
60
64
|
- `--tg`: forward the answer to Telegram via tgagentp gateway (error if unreachable)
|
|
61
65
|
- `--no-tg`: do not forward to Telegram
|
|
62
66
|
- `--flush`: flush tgagentp's recorded buffer without prepending it to output
|
|
@@ -69,9 +73,17 @@ Options:
|
|
|
69
73
|
By default, `--qa` auto-detects tgagentp (silently degrades if unavailable); standalone mode implies `--no-tg`.
|
|
70
74
|
With `--tg`, errors if tgagentp is unavailable.
|
|
71
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
|
+
|
|
72
83
|
Arguments:
|
|
73
84
|
|
|
74
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.
|
|
75
87
|
|
|
76
88
|
## Examples
|
|
77
89
|
|
|
@@ -142,6 +154,107 @@ From Vim/Neovim, flush the recorded buffer without prepending context:
|
|
|
142
154
|
Useful when you've finished a conversation thread and want to reset the recorded
|
|
143
155
|
context for a new topic.
|
|
144
156
|
|
|
157
|
+
Deferred execution with `--defer`:
|
|
158
|
+
|
|
159
|
+
Submit a prompt and get a ticket to retrieve the result later:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
# Submit a prompt and get a deferred ticket (optionally wait up to N seconds)
|
|
163
|
+
DEFERRED=$(printf "Refactor the authentication module" | agentp --defer)
|
|
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)
|
|
173
|
+
|
|
174
|
+
# Continue working... retrieve the result when ready
|
|
175
|
+
printf '%s\n' "$DEFERRED" | agentp --defer
|
|
176
|
+
# Output: (the agent's response)
|
|
177
|
+
```
|
|
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
|
+
|
|
236
|
+
Works as a Vim/Neovim filter with deferred execution:
|
|
237
|
+
|
|
238
|
+
```vim
|
|
239
|
+
" Submit selection, get ticket immediately, continue editing
|
|
240
|
+
:'<,'>!agentp --defer --qa
|
|
241
|
+
|
|
242
|
+
" Later, retrieve the result
|
|
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
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The deferred workflow:
|
|
250
|
+
1. Submit prompt with `--defer` → get a ticket (`agentp_ticket {...}`)
|
|
251
|
+
2. Continue working (the agent processes in background)
|
|
252
|
+
3. When ready, pipe the ticket back to `agentp --defer` to retrieve the result
|
|
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
|
|
255
|
+
|
|
256
|
+
Useful for long-running tasks where you don't want to block your editor.
|
|
257
|
+
|
|
145
258
|
Retrieve the last 3 assistant answers from session history:
|
|
146
259
|
|
|
147
260
|
```bash
|
|
@@ -168,6 +281,7 @@ Subcommands:
|
|
|
168
281
|
- `--print-logs` passes `--print-logs` to `opencode serve`, which prints server logs to stderr in the server tmux pane.
|
|
169
282
|
- **`kill [dir]`** — Kill the server found upward from `dir`. Removes its tmux window and state file.
|
|
170
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.
|
|
171
285
|
- **`list`** — List all running servers with their directories, URLs, and status.
|
|
172
286
|
|
|
173
287
|
Options:
|
|
@@ -181,6 +295,7 @@ Notes:
|
|
|
181
295
|
|
|
182
296
|
- If `<directory>` is not a valid path, `ocmux` tries to match it against the basenames of existing sessions (exact unique match).
|
|
183
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.
|
|
184
299
|
|
|
185
300
|
### Requirements
|
|
186
301
|
|
package/bin/agentp
CHANGED
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
const http = require('http');
|
|
4
4
|
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const crypto = require('crypto');
|
|
5
8
|
const readline = require('readline');
|
|
6
9
|
const child_process = require('child_process');
|
|
7
10
|
const { version } = require('../package.json');
|
|
8
11
|
const {
|
|
9
12
|
sendToSession,
|
|
13
|
+
sendToSessionAsync,
|
|
10
14
|
listSessions,
|
|
11
15
|
createSession,
|
|
12
16
|
getSession,
|
|
@@ -60,6 +64,261 @@ function notifyAgentpGateway(port, server, text) {
|
|
|
60
64
|
});
|
|
61
65
|
}
|
|
62
66
|
|
|
67
|
+
function isFileLocked(filePath) {
|
|
68
|
+
const lockPath = filePath + '.lock';
|
|
69
|
+
try {
|
|
70
|
+
if (!fs.existsSync(lockPath)) return false;
|
|
71
|
+
const pid = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10);
|
|
72
|
+
if (isNaN(pid)) return false;
|
|
73
|
+
// Check if process is still alive
|
|
74
|
+
try {
|
|
75
|
+
process.kill(pid, 0);
|
|
76
|
+
return true; // Process exists, file is locked
|
|
77
|
+
} catch (err) {
|
|
78
|
+
// Process doesn't exist, lock is stale
|
|
79
|
+
fs.unlinkSync(lockPath);
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function lockFile(filePath) {
|
|
88
|
+
const lockPath = filePath + '.lock';
|
|
89
|
+
fs.writeFileSync(lockPath, String(process.pid));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function unlockFile(filePath) {
|
|
93
|
+
const lockPath = filePath + '.lock';
|
|
94
|
+
try {
|
|
95
|
+
fs.unlinkSync(lockPath);
|
|
96
|
+
} catch {
|
|
97
|
+
// Ignore errors
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function generateTempFilename() {
|
|
102
|
+
const now = new Date();
|
|
103
|
+
const timestamp = now.getFullYear().toString() +
|
|
104
|
+
String(now.getMonth() + 1).padStart(2, '0') +
|
|
105
|
+
String(now.getDate()).padStart(2, '0') + '_' +
|
|
106
|
+
String(now.getHours()).padStart(2, '0') +
|
|
107
|
+
String(now.getMinutes()).padStart(2, '0');
|
|
108
|
+
const hash = crypto.randomBytes(2).toString('hex').slice(0, 4);
|
|
109
|
+
return path.join(os.tmpdir(), `agentp_deferred_${timestamp}_${hash}.tmp`);
|
|
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
|
+
|
|
63
322
|
async function readAllStdin() {
|
|
64
323
|
let text = '';
|
|
65
324
|
const rl = readline.createInterface({ input: process.stdin });
|
|
@@ -78,7 +337,14 @@ async function main() {
|
|
|
78
337
|
let serverBase = 'http://localhost:4096';
|
|
79
338
|
let qaMode = false;
|
|
80
339
|
let flushMode = false;
|
|
340
|
+
let deferMode = false;
|
|
341
|
+
let deferSeconds = 0;
|
|
342
|
+
let deferChildMode = false;
|
|
343
|
+
let onlineTicket = false;
|
|
344
|
+
let promptFile = null;
|
|
345
|
+
let outputFile = null;
|
|
81
346
|
let sessionName = null;
|
|
347
|
+
let resolvedSessionId = null;
|
|
82
348
|
let newSession = false;
|
|
83
349
|
// tgMode: false = disabled, 'auto' = auto-detect, true = explicit --tg
|
|
84
350
|
let tgMode = null;
|
|
@@ -96,6 +362,11 @@ async function main() {
|
|
|
96
362
|
console.error('Options:');
|
|
97
363
|
console.error(' --version Show version');
|
|
98
364
|
console.error(' --qa Print the original prompt and answer with labels (useful as a filter)');
|
|
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)');
|
|
99
370
|
console.error(' --tg Forward the answer to Telegram via agentp gateway (error if unavailable)');
|
|
100
371
|
console.error(' --no-tg Do not forward to Telegram');
|
|
101
372
|
console.error(' --flush Flush tgagentp\'s recorded buffer without prepending it to output');
|
|
@@ -121,7 +392,7 @@ async function main() {
|
|
|
121
392
|
console.error('Arguments:');
|
|
122
393
|
console.error(' url OpenCode server URL or port number (default: 4096)');
|
|
123
394
|
console.error(' Examples: 4096, http://localhost:4096, http://192.168.1.50:4096');
|
|
124
|
-
console.error('
|
|
395
|
+
console.error(' Tip: $(ocmux) expands to the URL of the current project\'s server.');
|
|
125
396
|
console.error('');
|
|
126
397
|
console.error('See also: tgagentp -- Telegram bridge (tgagentp --help), ocmux -- manage OpenCode tmux sessions (ocmux --help)');
|
|
127
398
|
console.error('');
|
|
@@ -139,6 +410,25 @@ async function main() {
|
|
|
139
410
|
process.exit(0);
|
|
140
411
|
} else if (args[i] === '--qa') {
|
|
141
412
|
qaMode = true;
|
|
413
|
+
} else if (args[i] === '--defer') {
|
|
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
|
+
}
|
|
422
|
+
} else if (args[i] === '--defer-child') {
|
|
423
|
+
deferChildMode = true;
|
|
424
|
+
} else if (args[i] === '--onlineTicket') {
|
|
425
|
+
onlineTicket = true;
|
|
426
|
+
} else if (args[i] === '--prompt-file') {
|
|
427
|
+
i++;
|
|
428
|
+
promptFile = args[i];
|
|
429
|
+
} else if (args[i] === '--output-file') {
|
|
430
|
+
i++;
|
|
431
|
+
outputFile = args[i];
|
|
142
432
|
} else if (args[i] === '--flush') {
|
|
143
433
|
flushMode = true;
|
|
144
434
|
} else if (args[i] === '--session') {
|
|
@@ -148,6 +438,13 @@ async function main() {
|
|
|
148
438
|
console.error('Error: --session requires a session name');
|
|
149
439
|
process.exit(1);
|
|
150
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
|
+
}
|
|
151
448
|
} else if (args[i] === '--new') {
|
|
152
449
|
newSession = true;
|
|
153
450
|
} else if (args[i] === '--getLast') {
|
|
@@ -289,113 +586,243 @@ async function main() {
|
|
|
289
586
|
|
|
290
587
|
const promptText = await readAllStdin();
|
|
291
588
|
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
} catch (err) {
|
|
297
|
-
console.error('Error connecting to server:', err.message);
|
|
298
|
-
process.exit(1);
|
|
299
|
-
}
|
|
589
|
+
// Handle --defer mode
|
|
590
|
+
if (deferMode) {
|
|
591
|
+
// Check if stdin is a deferred ticket
|
|
592
|
+
const ticket = parseDeferredTicketInput(promptText);
|
|
300
593
|
|
|
301
|
-
|
|
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;
|
|
302
598
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const matches = sessions.filter(s => {
|
|
306
|
-
const title = s.title || s.slug || s.id;
|
|
307
|
-
return title.toLowerCase() === sessionName.toLowerCase();
|
|
308
|
-
});
|
|
309
|
-
if (matches.length === 0) {
|
|
310
|
-
// Try partial match
|
|
311
|
-
const partial = sessions.filter(s => {
|
|
312
|
-
const title = s.title || s.slug || s.id;
|
|
313
|
-
return title.toLowerCase().includes(sessionName.toLowerCase());
|
|
314
|
-
});
|
|
315
|
-
if (partial.length === 1) {
|
|
316
|
-
sessionId = partial[0].id;
|
|
317
|
-
} else if (partial.length > 1) {
|
|
318
|
-
console.error(`Error: multiple sessions match "${sessionName}":`);
|
|
319
|
-
partial.forEach(s => console.error(` - ${s.title || s.slug || s.id}`));
|
|
599
|
+
if (!fs.existsSync(deferredPath)) {
|
|
600
|
+
process.stdout.write(`Error: deferred file not found: ${deferredPath}\n`);
|
|
320
601
|
process.exit(1);
|
|
321
|
-
}
|
|
322
|
-
|
|
602
|
+
}
|
|
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
|
|
323
610
|
try {
|
|
324
|
-
|
|
325
|
-
sessionId = session.id;
|
|
326
|
-
await selectSession(serverBase, sessionId);
|
|
611
|
+
fs.unlinkSync(deferredPath);
|
|
327
612
|
} catch (err) {
|
|
328
|
-
|
|
613
|
+
process.stdout.write(`Error reading deferred file: ${err.message}\n`);
|
|
329
614
|
process.exit(1);
|
|
330
615
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
process.exit(
|
|
616
|
+
try { fs.unlinkSync(deferredPath + '.lock'); } catch {}
|
|
617
|
+
process.stdout.write(formatDeferredResult(deferredPath, result, qaMode, ticket.followupText));
|
|
618
|
+
removeQueuedFollowups(deferredPath);
|
|
619
|
+
process.exit(0);
|
|
335
620
|
}
|
|
336
|
-
|
|
337
|
-
|
|
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
|
+
}
|
|
634
|
+
}
|
|
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
|
+
}
|
|
650
|
+
}
|
|
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');
|
|
657
|
+
process.exit(0);
|
|
338
658
|
}
|
|
339
|
-
} else if (sessions && sessions.length > 0) {
|
|
340
|
-
const sorted = sessions
|
|
341
|
-
.filter(s => s.time && (s.time.updated || s.time.created))
|
|
342
|
-
.sort((a, b) => {
|
|
343
|
-
const aTime = (a.time && a.time.updated) || (a.time && a.time.created) || 0;
|
|
344
|
-
const bTime = (b.time && b.time.updated) || (b.time && b.time.created) || 0;
|
|
345
|
-
return bTime - aTime;
|
|
346
|
-
});
|
|
347
|
-
if (sorted.length > 0) sessionId = sorted[0].id;
|
|
348
|
-
}
|
|
349
659
|
|
|
350
|
-
|
|
660
|
+
// This is a regular prompt — create deferred execution
|
|
661
|
+
const tempPath = generateTempFilename();
|
|
662
|
+
const promptTempPath = tempPath + '.prompt';
|
|
663
|
+
|
|
664
|
+
let targetSessionId;
|
|
351
665
|
try {
|
|
352
|
-
|
|
353
|
-
sessionId = session.id;
|
|
666
|
+
targetSessionId = await resolveTargetSession(serverBase, sessionName, newSession);
|
|
354
667
|
} catch (err) {
|
|
355
|
-
console.error(
|
|
668
|
+
console.error(err.message);
|
|
356
669
|
process.exit(1);
|
|
357
670
|
}
|
|
358
|
-
}
|
|
359
671
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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);
|
|
366
733
|
}
|
|
367
734
|
|
|
368
|
-
//
|
|
369
|
-
let
|
|
370
|
-
if (
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
735
|
+
// Handle --defer-child mode (child process)
|
|
736
|
+
let actualPromptText = promptText;
|
|
737
|
+
if (deferChildMode && promptFile) {
|
|
738
|
+
try {
|
|
739
|
+
actualPromptText = fs.readFileSync(promptFile, 'utf8');
|
|
740
|
+
fs.unlinkSync(promptFile);
|
|
741
|
+
} catch (err) {
|
|
742
|
+
const errMsg = `Error reading prompt file: ${err.message}\n`;
|
|
743
|
+
if (outputFile) { fs.writeFileSync(outputFile, errMsg); unlockFile(outputFile); }
|
|
744
|
+
process.stdout.write(errMsg);
|
|
745
|
+
process.exit(1);
|
|
746
|
+
}
|
|
374
747
|
}
|
|
375
748
|
|
|
376
|
-
//
|
|
377
|
-
let buffered = [];
|
|
749
|
+
// Wrap main processing in try-catch for deferChildMode to write errors to output file
|
|
378
750
|
let tgError = null;
|
|
379
|
-
|
|
751
|
+
async function runMain() {
|
|
752
|
+
const sessionId = resolvedSessionId || await resolveTargetSession(serverBase, sessionName, newSession);
|
|
753
|
+
|
|
754
|
+
let answer;
|
|
380
755
|
try {
|
|
381
|
-
|
|
756
|
+
answer = await sendToSession(serverBase, sessionId, actualPromptText);
|
|
382
757
|
} catch (err) {
|
|
383
|
-
|
|
758
|
+
throw new Error(`Error sending prompt: ${err.message}`);
|
|
384
759
|
}
|
|
385
|
-
}
|
|
386
760
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
if (
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
761
|
+
// Build the message to send to Telegram (QA pair with rulers)
|
|
762
|
+
let tgMessage = answer;
|
|
763
|
+
if (qaMode) {
|
|
764
|
+
tgMessage = HUMAN_ROW + actualPromptText + AGENT_ROW + answer;
|
|
765
|
+
if (!answer.endsWith('\n')) tgMessage += '\n';
|
|
766
|
+
tgMessage += FINAL_ROW;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Post-send: notify tgagentp, get recorded context back
|
|
770
|
+
let buffered = [];
|
|
771
|
+
if (tgPort) {
|
|
772
|
+
try {
|
|
773
|
+
buffered = await notifyAgentpGateway(tgPort, serverBase, tgMessage);
|
|
774
|
+
} catch (err) {
|
|
775
|
+
tgError = err;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Build output (prepend recorded context if any, unless --flush)
|
|
780
|
+
let output = '';
|
|
781
|
+
if (qaMode) {
|
|
782
|
+
if (!flushMode && buffered.length > 0) {
|
|
783
|
+
for (const msg of buffered) {
|
|
784
|
+
const row = msg.role === 'user' ? HUMAN_ROW : AGENT_ROW;
|
|
785
|
+
output += row + msg.text + '\n';
|
|
786
|
+
}
|
|
393
787
|
}
|
|
788
|
+
output += tgMessage;
|
|
789
|
+
} else {
|
|
790
|
+
output = tgMessage;
|
|
791
|
+
if (!tgMessage.endsWith('\n')) output += '\n';
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
return output;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Execute main processing with error handling for child mode
|
|
798
|
+
let output = '';
|
|
799
|
+
try {
|
|
800
|
+
output = await runMain();
|
|
801
|
+
} catch (err) {
|
|
802
|
+
const errMsg = err.message + '\n';
|
|
803
|
+
if (deferChildMode && outputFile) {
|
|
804
|
+
fs.writeFileSync(outputFile, errMsg);
|
|
805
|
+
unlockFile(outputFile);
|
|
806
|
+
} else {
|
|
807
|
+
console.error(err.message);
|
|
808
|
+
}
|
|
809
|
+
process.exit(1);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Write output to file (child mode) or stdout (normal mode)
|
|
813
|
+
let finalOutput = output;
|
|
814
|
+
if (deferChildMode && outputFile) {
|
|
815
|
+
try {
|
|
816
|
+
fs.writeFileSync(outputFile, output);
|
|
817
|
+
unlockFile(outputFile);
|
|
818
|
+
} catch (err) {
|
|
819
|
+
const errMsg = `Error writing output file: ${err.message}\n`;
|
|
820
|
+
try { fs.writeFileSync(outputFile, errMsg); } catch {}
|
|
821
|
+
unlockFile(outputFile);
|
|
822
|
+
process.exit(1);
|
|
394
823
|
}
|
|
395
|
-
process.stdout.write(tgMessage);
|
|
396
824
|
} else {
|
|
397
|
-
process.stdout.write(
|
|
398
|
-
if (!tgMessage.endsWith('\n')) process.stdout.write('\n');
|
|
825
|
+
process.stdout.write(output);
|
|
399
826
|
}
|
|
400
827
|
|
|
401
828
|
// Warnings at the end (stderr)
|
|
@@ -416,5 +843,4 @@ if (require.main === module) {
|
|
|
416
843
|
});
|
|
417
844
|
}
|
|
418
845
|
|
|
419
|
-
module.exports = { main, notifyAgentpGateway, readAllStdin };
|
|
420
|
-
|
|
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.
|
|
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 };
|
package/bin/tgagentp
CHANGED
|
@@ -1017,20 +1017,38 @@ async function processTelegramCommands(text, server, token, chatState) {
|
|
|
1017
1017
|
const filesSent = [];
|
|
1018
1018
|
const dir = serverDir(server);
|
|
1019
1019
|
|
|
1020
|
+
// Resolve a file path: absolute paths used directly, relative paths resolved
|
|
1021
|
+
// against the server's working directory. Returns null and sends an error
|
|
1022
|
+
// message if the resolved path is outside the server's working directory.
|
|
1023
|
+
function resolveUploadPath(p, errPrefix) {
|
|
1024
|
+
const absolute = path.isAbsolute(p) ? p : path.resolve(dir || '', p);
|
|
1025
|
+
if (!dir) return absolute; // no server dir — trust the path
|
|
1026
|
+
let realDir, realPath;
|
|
1027
|
+
try { realDir = fs.realpathSync(dir); } catch { realDir = dir; }
|
|
1028
|
+
try { realPath = fs.realpathSync(absolute); } catch { realPath = absolute; }
|
|
1029
|
+
if (!realPath.startsWith(realDir + path.sep) && realPath !== realDir) {
|
|
1030
|
+
sendLongMessage(token, chatState,
|
|
1031
|
+
`❌ ${errPrefix}: path outside project directory.\n` +
|
|
1032
|
+
` Path: \`${p}\`\n` +
|
|
1033
|
+
` Project: \`${realDir}\`\n\n` +
|
|
1034
|
+
`Use a full path (starting with /) or a path relative to the project root.`).catch(() => {});
|
|
1035
|
+
return null;
|
|
1036
|
+
}
|
|
1037
|
+
return absolute;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1020
1040
|
for (let i = 0; i < lines.length; i++) {
|
|
1021
1041
|
const payload = parseTelegramLine(lines[i]);
|
|
1022
1042
|
if (!payload) continue;
|
|
1023
1043
|
|
|
1024
1044
|
if (payload.command === 'upload' && payload.path) {
|
|
1025
1045
|
toStrip.add(i);
|
|
1026
|
-
const resolved =
|
|
1027
|
-
if (
|
|
1028
|
-
log.error(`processTelegramCommands: path outside project rejected: ${payload.path}`);
|
|
1029
|
-
continue;
|
|
1030
|
-
}
|
|
1046
|
+
const resolved = resolveUploadPath(payload.path, 'Upload');
|
|
1047
|
+
if (!resolved) continue;
|
|
1031
1048
|
try {
|
|
1032
1049
|
if (!fs.existsSync(resolved)) {
|
|
1033
|
-
|
|
1050
|
+
await sendLongMessage(token, chatState,
|
|
1051
|
+
`❌ File not found: \`${resolved}\``);
|
|
1034
1052
|
continue;
|
|
1035
1053
|
}
|
|
1036
1054
|
const fileData = fs.readFileSync(resolved);
|
|
@@ -1039,22 +1057,21 @@ async function processTelegramCommands(text, server, token, chatState) {
|
|
|
1039
1057
|
await sendTelegramDocument(token, chatState, fileData, fileName, caption);
|
|
1040
1058
|
filesSent.push(fileName);
|
|
1041
1059
|
} catch (err) {
|
|
1042
|
-
|
|
1060
|
+
await sendLongMessage(token, chatState,
|
|
1061
|
+
`❌ Failed to upload \`${resolved}\`: ${err.message}`);
|
|
1043
1062
|
}
|
|
1044
1063
|
} else if (payload.command === 'download' && payload.fileId && payload.path) {
|
|
1045
1064
|
toStrip.add(i);
|
|
1046
|
-
const resolved =
|
|
1047
|
-
if (
|
|
1048
|
-
log.error(`processTelegramCommands: download path outside project rejected: ${payload.path}`);
|
|
1049
|
-
continue;
|
|
1050
|
-
}
|
|
1065
|
+
const resolved = resolveUploadPath(payload.path, 'Download');
|
|
1066
|
+
if (!resolved) continue;
|
|
1051
1067
|
try {
|
|
1052
1068
|
const fileData = await downloadTelegramFile(token, payload.fileId);
|
|
1053
1069
|
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
1054
1070
|
fs.writeFileSync(resolved, fileData);
|
|
1055
1071
|
filesSent.push(path.basename(resolved));
|
|
1056
1072
|
} catch (err) {
|
|
1057
|
-
|
|
1073
|
+
await sendLongMessage(token, chatState,
|
|
1074
|
+
`❌ Failed to download file to \`${resolved}\`: ${err.message}`);
|
|
1058
1075
|
}
|
|
1059
1076
|
} else if (payload.command === 'help') {
|
|
1060
1077
|
toStrip.add(i);
|
|
@@ -2154,8 +2171,13 @@ async function main() {
|
|
|
2154
2171
|
'Do NOT use CLI tools to generate them — just include them directly in your response:\n\n' +
|
|
2155
2172
|
'To send a file to the user:\n' +
|
|
2156
2173
|
'[Telegram]{"command":"upload","path":"<relative-path>","msg":"<optional caption>"}\n\n' +
|
|
2174
|
+
'**Important:** `path` is relative to the project root (the OpenCode server\'s working directory).\n' +
|
|
2175
|
+
'If the project uses a git worktree, the worktree directory IS the project root.\n' +
|
|
2176
|
+
'Use CLI commands like `cat ./path/to/file` to verify the file exists before uploading.\n\n' +
|
|
2157
2177
|
'To download a file the user sent:\n' +
|
|
2158
2178
|
'[Telegram]{"command":"download","fileId":"<id>","path":"<relative-destination>"}\n\n' +
|
|
2179
|
+
'**Important:** `path` is also relative to the project root (the OpenCode server\'s working directory).\n' +
|
|
2180
|
+
'Use `ls` or `pwd` to confirm the current working directory before specifying download paths.\n\n' +
|
|
2159
2181
|
'For more details about a command:\n' +
|
|
2160
2182
|
'[Telegram]{"command":"help","topic":"<upload|download|help>"}';
|
|
2161
2183
|
|
|
@@ -2174,6 +2196,7 @@ async function main() {
|
|
|
2174
2196
|
const monRef = { current: null };
|
|
2175
2197
|
st._monitorRef = monRef;
|
|
2176
2198
|
st._monitorStarted = true;
|
|
2199
|
+
st._monitoredSessionId = sessionId;
|
|
2177
2200
|
|
|
2178
2201
|
const notifiedPerms = new Set();
|
|
2179
2202
|
|
|
@@ -2239,6 +2262,7 @@ async function main() {
|
|
|
2239
2262
|
if (getState(server)._monitorRef === monRef) {
|
|
2240
2263
|
getState(server)._monitorRef = null;
|
|
2241
2264
|
getState(server)._monitorStarted = false;
|
|
2265
|
+
getState(server)._monitoredSessionId = null;
|
|
2242
2266
|
}
|
|
2243
2267
|
});
|
|
2244
2268
|
}
|
|
@@ -2271,6 +2295,20 @@ async function main() {
|
|
|
2271
2295
|
|
|
2272
2296
|
async function processMessageAsync(chatId, text, url, useSession, sessionId, myTaskId, replyTo, threadId) {
|
|
2273
2297
|
const st = getState(url);
|
|
2298
|
+
// Auto-discover active session if not set
|
|
2299
|
+
if (!sessionId) {
|
|
2300
|
+
try {
|
|
2301
|
+
const discovered = await discoverActiveSession(url);
|
|
2302
|
+
if (discovered) {
|
|
2303
|
+
st.activeSessionId = discovered.id;
|
|
2304
|
+
st.activeSessionName = discovered.name;
|
|
2305
|
+
sessionId = discovered.id;
|
|
2306
|
+
useSession = true;
|
|
2307
|
+
}
|
|
2308
|
+
} catch (err) {
|
|
2309
|
+
log.debug(`processMessageAsync: auto-discover error: ${err.message}`);
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2274
2312
|
const isCancelled = () => getState(url).taskId !== myTaskId;
|
|
2275
2313
|
const cancelRef = { current: null };
|
|
2276
2314
|
const chatState = { chatId, threadId };
|
|
@@ -2281,11 +2319,12 @@ async function main() {
|
|
|
2281
2319
|
st._cancelRef = cancelRef;
|
|
2282
2320
|
st._sessionReq = sessionCancelRef;
|
|
2283
2321
|
|
|
2284
|
-
// Ensure the persistent session monitor is running
|
|
2285
|
-
//
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2322
|
+
// Ensure the persistent session monitor is running and tracking the current session.
|
|
2323
|
+
// Always restart when sessionId changes so TUI-initiated session switches are followed.
|
|
2324
|
+
if (useSession && sessionId) {
|
|
2325
|
+
if (!st._monitorStarted || st._monitoredSessionId !== sessionId) {
|
|
2326
|
+
startSessionMonitor(url, sessionId);
|
|
2327
|
+
}
|
|
2289
2328
|
}
|
|
2290
2329
|
|
|
2291
2330
|
try {
|
package/docs/specification.md
CHANGED
|
@@ -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:**
|