ticlawk 0.1.12-dev.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/LICENSE +15 -0
- package/README.md +426 -0
- package/agent-freeway.mjs +2 -0
- package/assets/ticlawk-concept.svg +137 -0
- package/bin/agent-freeway.mjs +4 -0
- package/bin/ticlawk.mjs +594 -0
- package/cc-watcher.mjs +3 -0
- package/package.json +72 -0
- package/scripts/postinstall.mjs +61 -0
- package/src/adapters/telegram/index.mjs +359 -0
- package/src/adapters/ticlawk/api.mjs +360 -0
- package/src/adapters/ticlawk/cards.mjs +149 -0
- package/src/adapters/ticlawk/credentials.mjs +25 -0
- package/src/adapters/ticlawk/index.mjs +1229 -0
- package/src/adapters/ticlawk/wake-client.mjs +204 -0
- package/src/core/adapter-registry.mjs +50 -0
- package/src/core/argv.mjs +38 -0
- package/src/core/bindings/store.mjs +81 -0
- package/src/core/bus.mjs +91 -0
- package/src/core/config.mjs +203 -0
- package/src/core/daemon-install.mjs +246 -0
- package/src/core/diagnostics.mjs +79 -0
- package/src/core/events/worker-events.mjs +80 -0
- package/src/core/executables.mjs +106 -0
- package/src/core/host-id.mjs +48 -0
- package/src/core/http.mjs +65 -0
- package/src/core/logger.mjs +34 -0
- package/src/core/media/inbound.mjs +127 -0
- package/src/core/media/outbound.mjs +163 -0
- package/src/core/profiles.mjs +173 -0
- package/src/core/runtime-contract.mjs +68 -0
- package/src/core/runtime-env.mjs +9 -0
- package/src/core/runtime-registry.mjs +93 -0
- package/src/core/runtime-support.mjs +197 -0
- package/src/core/setup-readiness.mjs +86 -0
- package/src/core/store/json-file-store.mjs +47 -0
- package/src/core/ticlawk-control.mjs +92 -0
- package/src/core/uninstall.mjs +142 -0
- package/src/core/update-state.mjs +62 -0
- package/src/core/update.mjs +178 -0
- package/src/runtimes/claude-code/index.mjs +363 -0
- package/src/runtimes/claude-code/session.mjs +388 -0
- package/src/runtimes/claude-code/transcripts.mjs +206 -0
- package/src/runtimes/codex/index.mjs +306 -0
- package/src/runtimes/codex/session.mjs +750 -0
- package/src/runtimes/openclaw/gateway.mjs +269 -0
- package/src/runtimes/openclaw/identity.mjs +34 -0
- package/src/runtimes/openclaw/index.mjs +228 -0
- package/src/runtimes/openclaw/inflight.mjs +46 -0
- package/src/runtimes/openclaw/target.mjs +57 -0
- package/src/runtimes/opencode/index.mjs +318 -0
- package/src/runtimes/opencode/session.mjs +413 -0
- package/src/runtimes/pi/index.mjs +287 -0
- package/src/runtimes/pi/session.mjs +423 -0
- package/ticlawk.mjs +260 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode local session helpers.
|
|
3
|
+
*
|
|
4
|
+
* opencode (https://opencode.ai) exposes a one-shot CLI mode via
|
|
5
|
+
* `opencode run [--session <id>] [--format json] <message>`. With
|
|
6
|
+
* `--format json` it emits newline-delimited JSON events on stdout
|
|
7
|
+
* during the turn — these carry the live `sessionID`, partial text,
|
|
8
|
+
* tool calls, and turn boundaries. The process exits when the turn
|
|
9
|
+
* finishes.
|
|
10
|
+
*
|
|
11
|
+
* Sessions are persisted under `~/.local/share/opencode/` as per-key
|
|
12
|
+
* JSON files (not append-only JSONL like Claude Code / Codex). For
|
|
13
|
+
* discovery we shell out to `opencode session list --format json`
|
|
14
|
+
* rather than walking the storage layout, since the list subcommand
|
|
15
|
+
* is the canonical interface and is robust to opencode's internal
|
|
16
|
+
* storage changes.
|
|
17
|
+
*
|
|
18
|
+
* NOTE: Verified against opencode 1.14.x:
|
|
19
|
+
* step_start -> { type, sessionID, part: { type: 'step-start', ... } }
|
|
20
|
+
* text -> { type, sessionID, part: { type: 'text', text: '...' } }
|
|
21
|
+
* step_finish -> { type, sessionID, part: { type: 'step-finish', reason, tokens, cost } }
|
|
22
|
+
* error -> { type, sessionID, message?, error? } (shape inferred)
|
|
23
|
+
* The text payload is at `event.part.text` and may arrive as a single
|
|
24
|
+
* batched event for short replies or as multiple events as the model
|
|
25
|
+
* streams. We treat each `text` event's payload as a delta and
|
|
26
|
+
* concatenate; if a future opencode version emits cumulative text we
|
|
27
|
+
* will need to switch to "last text wins".
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { spawn } from 'node:child_process';
|
|
31
|
+
import { homedir } from 'node:os';
|
|
32
|
+
import { debugLog, debugError } from '../../core/logger.mjs';
|
|
33
|
+
import { buildRuntimeEnv } from '../../core/runtime-env.mjs';
|
|
34
|
+
import { getRuntimeExecutableConfig } from '../../core/config.mjs';
|
|
35
|
+
import { isExecutablePath, resolveExecutable } from '../../core/executables.mjs';
|
|
36
|
+
|
|
37
|
+
export const OPENCODE_DATA_DIR = process.env.OPENCODE_DATA_DIR
|
|
38
|
+
|| `${process.env.XDG_DATA_HOME || `${homedir()}/.local/share`}/opencode`;
|
|
39
|
+
export const OPENCODE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
40
|
+
export const DEFAULT_OPENCODE_RUN_TIMEOUT_MS = 7200 * 1000;
|
|
41
|
+
export const DEFAULT_OPENCODE_COMMAND = 'opencode';
|
|
42
|
+
|
|
43
|
+
export function resolveOpenCodePath(preferredPath = null) {
|
|
44
|
+
return resolveExecutable({
|
|
45
|
+
command: DEFAULT_OPENCODE_COMMAND,
|
|
46
|
+
preferredPath,
|
|
47
|
+
configuredPath: getRuntimeExecutableConfig('opencode'),
|
|
48
|
+
envKey: 'OPENCODE_BIN',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function requireOpenCodePath(preferredPath = null) {
|
|
53
|
+
const requested = String(preferredPath || '').trim();
|
|
54
|
+
if (requested && requested.includes('/') && !isExecutablePath(requested)) {
|
|
55
|
+
throw new Error(`opencode binary is no longer available at: ${requested}. Reconnect this opencode agent or set OPENCODE_BIN / \`ticlawk config set runtimes.opencode.path <path>\`.`);
|
|
56
|
+
}
|
|
57
|
+
const opencodePath = resolveOpenCodePath(preferredPath);
|
|
58
|
+
if (opencodePath) return opencodePath;
|
|
59
|
+
throw new Error('opencode CLI not found. Install opencode, ensure it is on PATH, or set OPENCODE_BIN / `ticlawk config set runtimes.opencode.path <path>`.');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getOpenCodeRuntimeHealth(preferredPath = null) {
|
|
63
|
+
const opencodePath = resolveOpenCodePath(preferredPath);
|
|
64
|
+
return {
|
|
65
|
+
available: Boolean(opencodePath),
|
|
66
|
+
path: opencodePath,
|
|
67
|
+
// opencode's npm shim can hang and leave the native binary orphaned when
|
|
68
|
+
// called with --version. Health must stay side-effect free and fast.
|
|
69
|
+
version: null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildOpenCodeError(message, info) {
|
|
74
|
+
const wrapped = new Error(message);
|
|
75
|
+
wrapped.info = info;
|
|
76
|
+
return wrapped;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function extractEventText(event) {
|
|
80
|
+
if (!event || typeof event !== 'object') return '';
|
|
81
|
+
// Verified shape from `opencode run --format json` output (opencode 1.14.x):
|
|
82
|
+
// { type: 'text', sessionID, part: { type: 'text', text: '...' } }
|
|
83
|
+
// Fall through to flatter shapes for forward-compat.
|
|
84
|
+
const candidate = event.part?.text ?? event.text ?? event.delta ?? event.content;
|
|
85
|
+
return typeof candidate === 'string' ? candidate : '';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function extractSessionId(event) {
|
|
89
|
+
if (!event || typeof event !== 'object') return null;
|
|
90
|
+
return event.sessionID || event.session_id || event.sessionId || null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function extractErrorMessage(event) {
|
|
94
|
+
if (!event || typeof event !== 'object') return null;
|
|
95
|
+
// Verified shape from opencode 1.14.x: { type: 'error', error: { name, data: { message, statusCode, ... } } }
|
|
96
|
+
return event.error?.data?.message
|
|
97
|
+
|| event.error?.message
|
|
98
|
+
|| event.message
|
|
99
|
+
|| (typeof event.error === 'string' ? event.error : null)
|
|
100
|
+
|| null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Enumerate opencode sessions for a given project directory.
|
|
105
|
+
*
|
|
106
|
+
* `opencode session list --format json` is project-scoped — it only
|
|
107
|
+
* returns sessions when run from inside the project's directory. Without
|
|
108
|
+
* a cwd we cannot enumerate, so callers must always pass one. opencode
|
|
109
|
+
* 1.14.x stores sessions in a SQLite DB at `~/.local/share/opencode/`
|
|
110
|
+
* which is not stable to walk directly.
|
|
111
|
+
*/
|
|
112
|
+
export const DEFAULT_OPENCODE_DISCOVER_TIMEOUT_MS = 15000;
|
|
113
|
+
|
|
114
|
+
export async function discoverOpenCodeSessions(cwd, opts = {}) {
|
|
115
|
+
if (!cwd) return [];
|
|
116
|
+
const timeoutMs = Number(opts.timeoutMs || process.env.OPENCODE_DISCOVER_TIMEOUT_MS || DEFAULT_OPENCODE_DISCOVER_TIMEOUT_MS);
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
const opencodeCommand = requireOpenCodePath(opts.opencodePath || opts.runtimePath);
|
|
119
|
+
const child = spawn(opencodeCommand, ['session', 'list', '--format', 'json'], {
|
|
120
|
+
cwd,
|
|
121
|
+
env: buildRuntimeEnv(),
|
|
122
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
123
|
+
});
|
|
124
|
+
let stdout = '';
|
|
125
|
+
let settled = false;
|
|
126
|
+
const settle = (value) => {
|
|
127
|
+
if (settled) return;
|
|
128
|
+
settled = true;
|
|
129
|
+
if (timeout) clearTimeout(timeout);
|
|
130
|
+
resolve(value);
|
|
131
|
+
};
|
|
132
|
+
// `opencode session list` reads the SQLite DB — if the DB is locked
|
|
133
|
+
// by another opencode process or the CLI itself hangs we'd leave
|
|
134
|
+
// `ticlawk connect --session-id ...` blocking forever. Kill
|
|
135
|
+
// and return [] after a bounded wait so the connect call surfaces
|
|
136
|
+
// a clean "session not found" path instead.
|
|
137
|
+
const timeout = timeoutMs > 0 ? setTimeout(() => {
|
|
138
|
+
debugError('opencode', 'discover.timeout', { pid: child.pid, cwd, timeoutMs });
|
|
139
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
140
|
+
settle([]);
|
|
141
|
+
}, timeoutMs) : null;
|
|
142
|
+
timeout?.unref?.();
|
|
143
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
|
|
144
|
+
child.on('error', () => settle([]));
|
|
145
|
+
child.on('exit', (code) => {
|
|
146
|
+
if (code !== 0) { settle([]); return; }
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(stdout);
|
|
149
|
+
if (!Array.isArray(parsed)) { settle([]); return; }
|
|
150
|
+
settle(parsed
|
|
151
|
+
.map((entry) => {
|
|
152
|
+
const sessionId = entry?.id || entry?.sessionID || entry?.sessionId;
|
|
153
|
+
if (!sessionId) return null;
|
|
154
|
+
return {
|
|
155
|
+
sessionId: String(sessionId),
|
|
156
|
+
cwd: entry?.directory || entry?.cwd || cwd,
|
|
157
|
+
title: entry?.title || null,
|
|
158
|
+
raw: entry,
|
|
159
|
+
};
|
|
160
|
+
})
|
|
161
|
+
.filter(Boolean));
|
|
162
|
+
} catch {
|
|
163
|
+
settle([]);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function findOpenCodeSessionById(sessionId, cwd, opts = {}) {
|
|
170
|
+
if (!sessionId || !cwd) return null;
|
|
171
|
+
const sessions = await discoverOpenCodeSessions(cwd, opts);
|
|
172
|
+
return sessions.find((entry) => entry.sessionId === sessionId) || null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function buildOpenCodeRunArgs({ sessionId, message, files = [] }) {
|
|
176
|
+
const base = ['run', '--format', 'json', '--dangerously-skip-permissions'];
|
|
177
|
+
if (sessionId) {
|
|
178
|
+
base.push('--session', sessionId);
|
|
179
|
+
}
|
|
180
|
+
for (const file of files) {
|
|
181
|
+
if (typeof file === 'string' && file) {
|
|
182
|
+
base.push('--file', file);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// `--` ends option parsing. Without it, `--file` (array type) would
|
|
186
|
+
// greedily consume the message string as another file path and fail
|
|
187
|
+
// with "File not found: <message text>". Always use the delimiter so
|
|
188
|
+
// adding/removing files doesn't change the parsing semantics.
|
|
189
|
+
base.push('--', message);
|
|
190
|
+
return base;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Spawn `opencode run` and resolve with the final result.
|
|
195
|
+
*
|
|
196
|
+
* If `onEvent` is provided it is invoked for each parsed event during
|
|
197
|
+
* the turn — `{ type: 'turn.started', sessionId }`,
|
|
198
|
+
* `{ type: 'message.delta', sessionId, text }`, etc. — so callers can
|
|
199
|
+
* forward typing indicators / partial text to the chat client.
|
|
200
|
+
*
|
|
201
|
+
* Resolves with `{ sessionId, cwd, text, durationMs }` on success.
|
|
202
|
+
* Rejects with an Error whose `.info` carries `{ ok: false, kind, ... }`
|
|
203
|
+
* matching the shape `reportSubprocessFailure` expects.
|
|
204
|
+
*/
|
|
205
|
+
export function runOpenCodePrompt({
|
|
206
|
+
sessionId,
|
|
207
|
+
cwd,
|
|
208
|
+
message,
|
|
209
|
+
files = [],
|
|
210
|
+
opencodePath = null,
|
|
211
|
+
timeoutMs = Number(process.env.OPENCODE_RUN_TIMEOUT_MS || DEFAULT_OPENCODE_RUN_TIMEOUT_MS),
|
|
212
|
+
onEvent,
|
|
213
|
+
}) {
|
|
214
|
+
return new Promise((resolve, reject) => {
|
|
215
|
+
const startedAt = Date.now();
|
|
216
|
+
const opencodeCommand = requireOpenCodePath(opencodePath);
|
|
217
|
+
const child = spawn(opencodeCommand, buildOpenCodeRunArgs({ sessionId, message, files }), {
|
|
218
|
+
cwd,
|
|
219
|
+
env: buildRuntimeEnv(),
|
|
220
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
debugLog('opencode', 'run.spawned', {
|
|
224
|
+
pid: child.pid,
|
|
225
|
+
cwd,
|
|
226
|
+
resume: Boolean(sessionId),
|
|
227
|
+
timeoutMs,
|
|
228
|
+
textLength: message.length,
|
|
229
|
+
fileCount: Array.isArray(files) ? files.length : 0,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
let activeSessionId = sessionId || null;
|
|
233
|
+
let finalText = '';
|
|
234
|
+
let buffer = '';
|
|
235
|
+
let stdoutTail = '';
|
|
236
|
+
let lastError = null;
|
|
237
|
+
let settled = false;
|
|
238
|
+
let turnStartedEmitted = false;
|
|
239
|
+
let eventChain = Promise.resolve();
|
|
240
|
+
|
|
241
|
+
const emit = (event) => {
|
|
242
|
+
if (typeof onEvent !== 'function') return;
|
|
243
|
+
eventChain = eventChain.then(() => onEvent(event)).catch(() => {});
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const settle = (fn, value) => {
|
|
247
|
+
if (settled) return;
|
|
248
|
+
settled = true;
|
|
249
|
+
if (timeout) clearTimeout(timeout);
|
|
250
|
+
eventChain.catch(() => {}).finally(() => fn(value));
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const handleEvent = (event) => {
|
|
254
|
+
const eventSessionId = extractSessionId(event);
|
|
255
|
+
if (eventSessionId) activeSessionId = eventSessionId;
|
|
256
|
+
|
|
257
|
+
if (!turnStartedEmitted && (event?.type === 'step_start' || event?.type === 'turn.started')) {
|
|
258
|
+
turnStartedEmitted = true;
|
|
259
|
+
emit({ type: 'turn.started', sessionId: activeSessionId });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (event?.type === 'text' || event?.type === 'reasoning') {
|
|
263
|
+
const delta = extractEventText(event);
|
|
264
|
+
if (delta) {
|
|
265
|
+
finalText += delta;
|
|
266
|
+
emit({ type: 'message.delta', sessionId: activeSessionId, text: delta });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (event?.type === 'error') {
|
|
271
|
+
lastError = extractErrorMessage(event) || 'opencode error';
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
child.stdout.on('data', (chunk) => {
|
|
276
|
+
buffer += chunk.toString('utf8');
|
|
277
|
+
stdoutTail = (stdoutTail + chunk.toString('utf8')).slice(-4000);
|
|
278
|
+
const lines = buffer.split('\n');
|
|
279
|
+
buffer = lines.pop() || '';
|
|
280
|
+
for (const line of lines) {
|
|
281
|
+
const trimmed = line.trim();
|
|
282
|
+
if (!trimmed) continue;
|
|
283
|
+
let event;
|
|
284
|
+
try { event = JSON.parse(trimmed); } catch { continue; }
|
|
285
|
+
handleEvent(event);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
let timeout = null;
|
|
290
|
+
if (timeoutMs > 0) {
|
|
291
|
+
timeout = setTimeout(() => {
|
|
292
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
293
|
+
settle(reject, buildOpenCodeError('opencode run timed out', {
|
|
294
|
+
ok: false,
|
|
295
|
+
code: null,
|
|
296
|
+
signal: 'SIGTERM',
|
|
297
|
+
durationMs: Date.now() - startedAt,
|
|
298
|
+
kind: 'killed',
|
|
299
|
+
errorMessage: 'opencode run timed out',
|
|
300
|
+
}));
|
|
301
|
+
}, timeoutMs);
|
|
302
|
+
timeout.unref();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
child.on('error', (err) => {
|
|
306
|
+
debugError('opencode', 'run.spawn-failed', {
|
|
307
|
+
pid: child.pid,
|
|
308
|
+
durationMs: Date.now() - startedAt,
|
|
309
|
+
error: err.message,
|
|
310
|
+
});
|
|
311
|
+
settle(reject, buildOpenCodeError(err.message || 'opencode spawn failed', {
|
|
312
|
+
ok: false,
|
|
313
|
+
code: null,
|
|
314
|
+
signal: null,
|
|
315
|
+
durationMs: Date.now() - startedAt,
|
|
316
|
+
kind: 'spawn-failed',
|
|
317
|
+
error: err.message,
|
|
318
|
+
}));
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
child.on('exit', (code, signal) => {
|
|
322
|
+
// opencode's stdout chunk handler keeps any incomplete final line
|
|
323
|
+
// in `buffer` waiting for the next \n. If the process exits before
|
|
324
|
+
// the last line is terminated we lose that event — which can be
|
|
325
|
+
// the only `text` (so we'd resolve with empty text) or the only
|
|
326
|
+
// event carrying `sessionID` (so we'd reject with "sessionID
|
|
327
|
+
// missing"). Flush the remaining buffer through handleEvent
|
|
328
|
+
// before deciding the outcome.
|
|
329
|
+
if (buffer) {
|
|
330
|
+
const trimmed = buffer.trim();
|
|
331
|
+
buffer = '';
|
|
332
|
+
if (trimmed) {
|
|
333
|
+
try {
|
|
334
|
+
handleEvent(JSON.parse(trimmed));
|
|
335
|
+
} catch {
|
|
336
|
+
// malformed final fragment — fall through to normal exit handling
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const durationMs = Date.now() - startedAt;
|
|
341
|
+
if (signal) {
|
|
342
|
+
settle(reject, buildOpenCodeError(lastError || `opencode killed by ${signal}`, {
|
|
343
|
+
ok: false,
|
|
344
|
+
code: null,
|
|
345
|
+
signal,
|
|
346
|
+
durationMs,
|
|
347
|
+
kind: 'killed',
|
|
348
|
+
errorMessage: lastError,
|
|
349
|
+
}));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (code !== 0) {
|
|
353
|
+
settle(reject, buildOpenCodeError(lastError || `opencode exited with code ${code}`, {
|
|
354
|
+
ok: false,
|
|
355
|
+
code,
|
|
356
|
+
signal: null,
|
|
357
|
+
durationMs,
|
|
358
|
+
kind: 'exit-error',
|
|
359
|
+
errorMessage: lastError || stdoutTail || null,
|
|
360
|
+
}));
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (!activeSessionId) {
|
|
364
|
+
settle(reject, buildOpenCodeError('opencode sessionID missing from output', {
|
|
365
|
+
ok: false,
|
|
366
|
+
code: 0,
|
|
367
|
+
signal: null,
|
|
368
|
+
durationMs,
|
|
369
|
+
kind: 'invalid-output',
|
|
370
|
+
errorMessage: 'opencode sessionID missing from output',
|
|
371
|
+
}));
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
// opencode exits with code 0 even when an `error` event was emitted
|
|
375
|
+
// mid-run (e.g. provider 4xx). Treat any captured error as a turn
|
|
376
|
+
// failure rather than resolving with the empty text it accumulated.
|
|
377
|
+
if (lastError && !finalText) {
|
|
378
|
+
settle(reject, buildOpenCodeError(lastError, {
|
|
379
|
+
ok: false,
|
|
380
|
+
code: 0,
|
|
381
|
+
signal: null,
|
|
382
|
+
durationMs,
|
|
383
|
+
kind: 'gateway-error',
|
|
384
|
+
errorMessage: lastError,
|
|
385
|
+
}));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
settle(resolve, {
|
|
389
|
+
sessionId: activeSessionId,
|
|
390
|
+
cwd,
|
|
391
|
+
text: finalText,
|
|
392
|
+
durationMs,
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function streamOpenCodePrompt(opts) {
|
|
399
|
+
return runOpenCodePrompt(opts);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Create a fresh opencode session by running with no `--session`. The
|
|
404
|
+
* sessionID is captured from the first event that carries one.
|
|
405
|
+
*/
|
|
406
|
+
export async function createOpenCodeSession({ cwd, message, opencodePath = null, timeoutMs }) {
|
|
407
|
+
const result = await runOpenCodePrompt({ sessionId: null, cwd, message, opencodePath, timeoutMs });
|
|
408
|
+
return {
|
|
409
|
+
sessionId: result.sessionId,
|
|
410
|
+
cwd: result.cwd || cwd,
|
|
411
|
+
text: result.text,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi runtime entry point.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the pi CLI RPC mode and exposes the ticlawk runtime contract.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { basename } from 'node:path';
|
|
9
|
+
import {
|
|
10
|
+
buildPiImagesFromInbound,
|
|
11
|
+
discoverPiSessions,
|
|
12
|
+
findPiSessionById,
|
|
13
|
+
getPiRuntimeHealth,
|
|
14
|
+
PI_MAX_AGE_MS,
|
|
15
|
+
PI_SESSIONS_DIR,
|
|
16
|
+
requirePiPath,
|
|
17
|
+
runPiPrompt,
|
|
18
|
+
} from './session.mjs';
|
|
19
|
+
import { emitWorkerEvent } from '../../core/events/worker-events.mjs';
|
|
20
|
+
import {
|
|
21
|
+
shouldStreamRuntime,
|
|
22
|
+
createDeltaAggregator,
|
|
23
|
+
sendAdapterMessage,
|
|
24
|
+
sendResult,
|
|
25
|
+
reportSubprocessFailure,
|
|
26
|
+
terminalRuntimeFailure,
|
|
27
|
+
updateBindingRuntimeMeta,
|
|
28
|
+
} from '../../core/runtime-support.mjs';
|
|
29
|
+
|
|
30
|
+
export const piRuntime = {
|
|
31
|
+
name: 'pi',
|
|
32
|
+
|
|
33
|
+
runTurn({ sessionId, cwd, piPath }, text, opts = {}) {
|
|
34
|
+
return runPiPrompt({
|
|
35
|
+
sessionId,
|
|
36
|
+
cwd,
|
|
37
|
+
message: text,
|
|
38
|
+
images: opts.images,
|
|
39
|
+
piPath,
|
|
40
|
+
timeoutMs: opts.timeoutMs,
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
runTurnStream({ sessionId, cwd, piPath }, text, opts = {}) {
|
|
45
|
+
return runPiPrompt({
|
|
46
|
+
sessionId,
|
|
47
|
+
cwd,
|
|
48
|
+
message: text,
|
|
49
|
+
images: opts.images,
|
|
50
|
+
piPath,
|
|
51
|
+
timeoutMs: opts.timeoutMs,
|
|
52
|
+
onEvent: opts.onEvent,
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
async listLocalSessions(cwd = null) {
|
|
57
|
+
return discoverPiSessions(cwd);
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async resolveBinding(payload) {
|
|
61
|
+
const requestedCwd = String(payload?.cwd || payload?.workdir || payload?.projectDir || '').trim();
|
|
62
|
+
const piPath = requirePiPath(payload?.piPath || payload?.runtimePath);
|
|
63
|
+
const piVersion = getPiRuntimeHealth(piPath).version;
|
|
64
|
+
|
|
65
|
+
if (payload?.sessionId) {
|
|
66
|
+
const session = findPiSessionById(payload.sessionId, requestedCwd || null);
|
|
67
|
+
if (!session) {
|
|
68
|
+
throw new Error(requestedCwd
|
|
69
|
+
? `pi session ${payload.sessionId} not found in ${requestedCwd}`
|
|
70
|
+
: `pi session ${payload.sessionId} not found locally`);
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
runtime: this.name,
|
|
74
|
+
displayName: payload.name || basename(session.cwd || requestedCwd) || 'pi',
|
|
75
|
+
runtimeMeta: {
|
|
76
|
+
sessionId: session.sessionId,
|
|
77
|
+
workdir: session.cwd || requestedCwd,
|
|
78
|
+
cwd: session.cwd || requestedCwd,
|
|
79
|
+
path: session.path || null,
|
|
80
|
+
runtimePath: piPath,
|
|
81
|
+
piPath,
|
|
82
|
+
piVersion,
|
|
83
|
+
rotatePending: false,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!requestedCwd) {
|
|
89
|
+
throw new Error('cwd or sessionId is required for pi binding');
|
|
90
|
+
}
|
|
91
|
+
if (!existsSync(requestedCwd)) {
|
|
92
|
+
throw new Error(`pi cwd not found locally: ${requestedCwd}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
runtime: this.name,
|
|
97
|
+
displayName: payload.name || basename(requestedCwd) || 'pi',
|
|
98
|
+
runtimeMeta: {
|
|
99
|
+
sessionId: null,
|
|
100
|
+
workdir: requestedCwd,
|
|
101
|
+
cwd: requestedCwd,
|
|
102
|
+
path: null,
|
|
103
|
+
runtimePath: piPath,
|
|
104
|
+
piPath,
|
|
105
|
+
piVersion,
|
|
106
|
+
rotatePending: false,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
async health(meta = {}) {
|
|
112
|
+
return getPiRuntimeHealth(meta?.piPath || meta?.runtimePath);
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
async deliverTurn(inbound, ctx) {
|
|
116
|
+
const binding = ctx.getBinding(inbound.bindingId);
|
|
117
|
+
if (!binding) return false;
|
|
118
|
+
const adapter = ctx.adapter;
|
|
119
|
+
const meta = binding.runtimeMeta || {};
|
|
120
|
+
|
|
121
|
+
if (!meta.cwd || !existsSync(meta.cwd)) {
|
|
122
|
+
await sendAdapterMessage(adapter, binding, {
|
|
123
|
+
type: 'assistant',
|
|
124
|
+
text: `⚠️ pi cwd not found: ${meta.cwd || '(missing)'}`,
|
|
125
|
+
media: [],
|
|
126
|
+
replyToMessageId: inbound.messageId || null,
|
|
127
|
+
});
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let images = [];
|
|
132
|
+
let message = inbound.text || '';
|
|
133
|
+
if (inbound.action === 'image') {
|
|
134
|
+
images = await buildPiImagesFromInbound(inbound);
|
|
135
|
+
const captionText = (inbound.text || '').trim();
|
|
136
|
+
if (images.length === 0 && !captionText) {
|
|
137
|
+
await sendAdapterMessage(adapter, binding, {
|
|
138
|
+
type: 'assistant',
|
|
139
|
+
text: '⚠️ Image attached, but I could not access the image data and there is no caption to fall back on. Try sending the image again, or include a text caption.',
|
|
140
|
+
media: [],
|
|
141
|
+
replyToMessageId: inbound.messageId || null,
|
|
142
|
+
});
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
if (images.length === 0 && captionText) {
|
|
146
|
+
await sendAdapterMessage(adapter, binding, {
|
|
147
|
+
type: 'assistant',
|
|
148
|
+
text: '⚠️ Could not access the attached image data; acting on the caption text only.',
|
|
149
|
+
media: [],
|
|
150
|
+
replyToMessageId: inbound.messageId || null,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
message = captionText || 'Please analyze the attached image(s).';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const shouldRotate = !meta.sessionId || meta.rotatePending;
|
|
157
|
+
const deltaAggregator = createDeltaAggregator({
|
|
158
|
+
flushDelta: async ({ text, sessionId, cwd }) => {
|
|
159
|
+
await emitWorkerEvent({
|
|
160
|
+
adapter,
|
|
161
|
+
binding,
|
|
162
|
+
agent: this.name,
|
|
163
|
+
sessionId: sessionId || meta.sessionId || binding.id,
|
|
164
|
+
cwd: cwd || meta.cwd,
|
|
165
|
+
replyToMessageId: inbound.messageId || null,
|
|
166
|
+
event: {
|
|
167
|
+
hook_event_name: 'worker.message.delta',
|
|
168
|
+
worker_event_name: 'worker.message.delta',
|
|
169
|
+
delta: text,
|
|
170
|
+
},
|
|
171
|
+
logger: ctx.logger,
|
|
172
|
+
});
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const runtimePiPath = requirePiPath(meta.piPath || meta.runtimePath);
|
|
178
|
+
const runtimePiVersion = getPiRuntimeHealth(runtimePiPath).version || meta.piVersion || null;
|
|
179
|
+
const result = shouldStreamRuntime(this.name, this)
|
|
180
|
+
? await this.runTurnStream({ sessionId: shouldRotate ? null : meta.sessionId, cwd: meta.cwd, piPath: runtimePiPath }, message, {
|
|
181
|
+
images,
|
|
182
|
+
onEvent: async (event) => {
|
|
183
|
+
if (event?.type === 'turn.started') {
|
|
184
|
+
await emitWorkerEvent({
|
|
185
|
+
adapter,
|
|
186
|
+
binding,
|
|
187
|
+
agent: this.name,
|
|
188
|
+
sessionId: event.sessionId || meta.sessionId || binding.id,
|
|
189
|
+
cwd: meta.cwd,
|
|
190
|
+
replyToMessageId: inbound.messageId || null,
|
|
191
|
+
event: {
|
|
192
|
+
hook_event_name: 'worker.turn.start',
|
|
193
|
+
worker_event_name: 'worker.turn.start',
|
|
194
|
+
},
|
|
195
|
+
logger: ctx.logger,
|
|
196
|
+
});
|
|
197
|
+
} else if (event?.type === 'message.delta' && event.text) {
|
|
198
|
+
deltaAggregator.push(event.text, {
|
|
199
|
+
sessionId: event.sessionId || meta.sessionId || binding.id,
|
|
200
|
+
cwd: meta.cwd,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
})
|
|
205
|
+
: await this.runTurn({ sessionId: shouldRotate ? null : meta.sessionId, cwd: meta.cwd, piPath: runtimePiPath }, message, { images });
|
|
206
|
+
|
|
207
|
+
await deltaAggregator.flush();
|
|
208
|
+
const nextBinding = await updateBindingRuntimeMeta(ctx, binding, {
|
|
209
|
+
sessionId: result?.sessionId || meta.sessionId,
|
|
210
|
+
cwd: result?.cwd || meta.cwd,
|
|
211
|
+
path: result?.path || meta.path || null,
|
|
212
|
+
runtimePath: runtimePiPath,
|
|
213
|
+
piPath: runtimePiPath,
|
|
214
|
+
piVersion: runtimePiVersion,
|
|
215
|
+
rotatePending: false,
|
|
216
|
+
lastRotatedAt: shouldRotate ? new Date().toISOString() : meta.lastRotatedAt,
|
|
217
|
+
}, { status: 'connected' });
|
|
218
|
+
await sendResult(adapter, nextBinding, inbound, result);
|
|
219
|
+
await emitWorkerEvent({
|
|
220
|
+
adapter,
|
|
221
|
+
binding: nextBinding,
|
|
222
|
+
agent: this.name,
|
|
223
|
+
sessionId: result?.sessionId || meta.sessionId || binding.id,
|
|
224
|
+
cwd: result?.cwd || meta.cwd,
|
|
225
|
+
replyToMessageId: inbound.messageId || null,
|
|
226
|
+
event: {
|
|
227
|
+
hook_event_name: 'Stop',
|
|
228
|
+
worker_event_name: 'worker.turn.complete',
|
|
229
|
+
},
|
|
230
|
+
logger: ctx.logger,
|
|
231
|
+
});
|
|
232
|
+
return true;
|
|
233
|
+
} catch (err) {
|
|
234
|
+
await deltaAggregator.flush().catch(() => {});
|
|
235
|
+
await emitWorkerEvent({
|
|
236
|
+
adapter,
|
|
237
|
+
binding,
|
|
238
|
+
agent: this.name,
|
|
239
|
+
sessionId: meta.sessionId || binding.id,
|
|
240
|
+
cwd: meta.cwd,
|
|
241
|
+
replyToMessageId: inbound.messageId || null,
|
|
242
|
+
event: {
|
|
243
|
+
hook_event_name: 'worker.turn.error',
|
|
244
|
+
worker_event_name: 'worker.turn.error',
|
|
245
|
+
error: err?.message || 'pi failed',
|
|
246
|
+
},
|
|
247
|
+
logger: ctx.logger,
|
|
248
|
+
});
|
|
249
|
+
await reportSubprocessFailure({
|
|
250
|
+
adapter,
|
|
251
|
+
binding,
|
|
252
|
+
inbound,
|
|
253
|
+
runtimeName: 'pi',
|
|
254
|
+
info: err?.info || {
|
|
255
|
+
ok: false,
|
|
256
|
+
kind: 'exit-error',
|
|
257
|
+
errorMessage: err?.message || 'pi failed',
|
|
258
|
+
durationMs: 0,
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
return terminalRuntimeFailure(err?.message || 'pi failed');
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
async reconcileAfterRestart(binding, ctx) {
|
|
266
|
+
const meta = binding.runtimeMeta || {};
|
|
267
|
+
await emitWorkerEvent({
|
|
268
|
+
adapter: ctx.adapter,
|
|
269
|
+
binding,
|
|
270
|
+
agent: this.name,
|
|
271
|
+
sessionId: meta.sessionId || binding.id,
|
|
272
|
+
cwd: meta.cwd || '',
|
|
273
|
+
event: {
|
|
274
|
+
hook_event_name: 'Stop',
|
|
275
|
+
worker_event_name: 'worker.turn.complete',
|
|
276
|
+
reason: 'connector.restart.reconcile',
|
|
277
|
+
},
|
|
278
|
+
logger: ctx.logger,
|
|
279
|
+
});
|
|
280
|
+
return 1;
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
sessionsDir: PI_SESSIONS_DIR,
|
|
284
|
+
maxAgeMs: PI_MAX_AGE_MS,
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
export default piRuntime;
|