pikiclaw 0.2.35
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 +21 -0
- package/README.md +315 -0
- package/dist/agent-driver.js +24 -0
- package/dist/bot-command-ui.js +299 -0
- package/dist/bot-commands.js +236 -0
- package/dist/bot-feishu-render.js +527 -0
- package/dist/bot-feishu.js +752 -0
- package/dist/bot-handler.js +115 -0
- package/dist/bot-menu.js +44 -0
- package/dist/bot-streaming.js +165 -0
- package/dist/bot-telegram-directory.js +74 -0
- package/dist/bot-telegram-live-preview.js +192 -0
- package/dist/bot-telegram-render.js +369 -0
- package/dist/bot-telegram.js +789 -0
- package/dist/bot.js +897 -0
- package/dist/channel-base.js +46 -0
- package/dist/channel-feishu.js +873 -0
- package/dist/channel-states.js +3 -0
- package/dist/channel-telegram.js +773 -0
- package/dist/cli-channels.js +24 -0
- package/dist/cli.js +484 -0
- package/dist/code-agent.js +1080 -0
- package/dist/config-validation.js +244 -0
- package/dist/dashboard-ui.js +31 -0
- package/dist/dashboard.js +840 -0
- package/dist/driver-claude.js +520 -0
- package/dist/driver-codex.js +1055 -0
- package/dist/driver-gemini.js +230 -0
- package/dist/mcp-bridge.js +192 -0
- package/dist/mcp-session-server.js +321 -0
- package/dist/onboarding.js +138 -0
- package/dist/process-control.js +259 -0
- package/dist/run.js +275 -0
- package/dist/session-status.js +43 -0
- package/dist/setup-wizard.js +231 -0
- package/dist/user-config.js +195 -0
- package/package.json +60 -0
|
@@ -0,0 +1,1080 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* code-agent.ts — Shared agent layer: types, session management, artifact helpers,
|
|
3
|
+
* CLI spawn framework, and unified entry points that delegate to per-agent drivers.
|
|
4
|
+
*
|
|
5
|
+
* Agent-specific logic lives in driver-claude.ts, driver-codex.ts, driver-gemini.ts.
|
|
6
|
+
*/
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import { execSync, spawn } from 'node:child_process';
|
|
9
|
+
import { createInterface } from 'node:readline';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { getDriver, allDrivers } from './agent-driver.js';
|
|
13
|
+
import { terminateProcessTree } from './process-control.js';
|
|
14
|
+
export { registerDriver, getDriver, allDrivers, allDriverIds, hasDriver, shutdownAllDrivers } from './agent-driver.js';
|
|
15
|
+
// Load all drivers (side-effect: each calls registerDriver)
|
|
16
|
+
import './driver-claude.js';
|
|
17
|
+
import './driver-codex.js';
|
|
18
|
+
import './driver-gemini.js';
|
|
19
|
+
// Re-export driver-specific functions that external code imports directly
|
|
20
|
+
export { doClaudeStream } from './driver-claude.js';
|
|
21
|
+
export { doCodexStream, buildCodexTurnInput, shutdownCodexServer, getCodexUsageLive } from './driver-codex.js';
|
|
22
|
+
export { doGeminiStream } from './driver-gemini.js';
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Shared utilities (exported for drivers)
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
export const Q = (a) => /[^a-zA-Z0-9_./:=@-]/.test(a) ? `'${a.replace(/'/g, "'\\''")}'` : a;
|
|
27
|
+
const AGENT_DETECT_TTL_MS = 1_000;
|
|
28
|
+
const AGENT_VERSION_TTL_MS = 5 * 60_000;
|
|
29
|
+
const AGENT_VERSION_TIMEOUT_MS = 900;
|
|
30
|
+
const agentDetectCache = new Map();
|
|
31
|
+
export function agentLog(msg) {
|
|
32
|
+
const ts = new Date().toTimeString().slice(0, 8);
|
|
33
|
+
process.stdout.write(`[agent ${ts}] ${msg}\n`);
|
|
34
|
+
}
|
|
35
|
+
export function dedupeStrings(values) {
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
const deduped = [];
|
|
38
|
+
for (const value of values) {
|
|
39
|
+
const item = String(value || '').trim();
|
|
40
|
+
if (!item || seen.has(item))
|
|
41
|
+
continue;
|
|
42
|
+
seen.add(item);
|
|
43
|
+
deduped.push(item);
|
|
44
|
+
}
|
|
45
|
+
return deduped;
|
|
46
|
+
}
|
|
47
|
+
function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); }
|
|
48
|
+
function readJsonFile(filePath, fallback) {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return fallback;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function writeJsonFile(filePath, value) {
|
|
57
|
+
ensureDir(path.dirname(filePath));
|
|
58
|
+
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
59
|
+
fs.writeFileSync(tmpPath, JSON.stringify(value, null, 2));
|
|
60
|
+
fs.renameSync(tmpPath, filePath);
|
|
61
|
+
}
|
|
62
|
+
function removeFileIfExists(filePath) { try {
|
|
63
|
+
fs.rmSync(filePath, { force: true });
|
|
64
|
+
}
|
|
65
|
+
catch { } }
|
|
66
|
+
export function numberOrNull(...values) {
|
|
67
|
+
for (const value of values) {
|
|
68
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
export function normalizeActivityLine(text) { return text.replace(/\s+/g, ' ').trim(); }
|
|
74
|
+
export function pushRecentActivity(lines, line, maxLines = 12) {
|
|
75
|
+
const cleaned = normalizeActivityLine(line);
|
|
76
|
+
if (!cleaned)
|
|
77
|
+
return;
|
|
78
|
+
if (lines[lines.length - 1] === cleaned)
|
|
79
|
+
return;
|
|
80
|
+
lines.push(cleaned);
|
|
81
|
+
if (lines.length > maxLines)
|
|
82
|
+
lines.splice(0, lines.length - maxLines);
|
|
83
|
+
}
|
|
84
|
+
export function firstNonEmptyLine(text) {
|
|
85
|
+
for (const line of String(text || '').split('\n')) {
|
|
86
|
+
const trimmed = line.trim();
|
|
87
|
+
if (trimmed)
|
|
88
|
+
return trimmed;
|
|
89
|
+
}
|
|
90
|
+
return '';
|
|
91
|
+
}
|
|
92
|
+
export function shortValue(value, max = 90) {
|
|
93
|
+
const text = typeof value === 'string' ? value.trim() : value == null ? '' : String(value).trim();
|
|
94
|
+
if (!text)
|
|
95
|
+
return '';
|
|
96
|
+
if (text.length <= max)
|
|
97
|
+
return text;
|
|
98
|
+
return `${text.slice(0, Math.max(0, max - 3)).trimEnd()}...`;
|
|
99
|
+
}
|
|
100
|
+
export function appendSystemPrompt(base, extra) {
|
|
101
|
+
const lhs = String(base || '').trim();
|
|
102
|
+
const rhs = String(extra || '').trim();
|
|
103
|
+
if (!lhs)
|
|
104
|
+
return rhs;
|
|
105
|
+
if (!rhs)
|
|
106
|
+
return lhs;
|
|
107
|
+
return `${lhs}\n\n${rhs}`;
|
|
108
|
+
}
|
|
109
|
+
export const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
|
|
110
|
+
export function mimeForExt(ext) {
|
|
111
|
+
switch (ext) {
|
|
112
|
+
case '.jpg':
|
|
113
|
+
case '.jpeg': return 'image/jpeg';
|
|
114
|
+
case '.png': return 'image/png';
|
|
115
|
+
case '.gif': return 'image/gif';
|
|
116
|
+
case '.webp': return 'image/webp';
|
|
117
|
+
default: return 'application/octet-stream';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export function computeContext(s) {
|
|
121
|
+
const total = (s.inputTokens ?? 0) + (s.cachedInputTokens ?? 0) + (s.cacheCreationInputTokens ?? 0);
|
|
122
|
+
const used = total > 0 ? total : null;
|
|
123
|
+
const pct = used != null && s.contextWindow
|
|
124
|
+
? Math.min(99.9, Math.round(used / s.contextWindow * 1000) / 10)
|
|
125
|
+
: null;
|
|
126
|
+
return { contextUsedTokens: used, contextPercent: pct };
|
|
127
|
+
}
|
|
128
|
+
export function buildStreamPreviewMeta(s) {
|
|
129
|
+
return {
|
|
130
|
+
inputTokens: s.inputTokens, outputTokens: s.outputTokens,
|
|
131
|
+
cachedInputTokens: s.cachedInputTokens, contextPercent: computeContext(s).contextPercent,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
// Claude tool use helpers (used by driver-claude.ts)
|
|
135
|
+
export function summarizeClaudeToolUse(name, input) {
|
|
136
|
+
const tool = String(name || '').trim() || 'Tool';
|
|
137
|
+
const description = shortValue(input?.description, 120);
|
|
138
|
+
switch (tool) {
|
|
139
|
+
case 'Read': {
|
|
140
|
+
const t = shortValue(input?.file_path || input?.path, 140);
|
|
141
|
+
return t ? `Read ${t}` : 'Read file';
|
|
142
|
+
}
|
|
143
|
+
case 'Edit': {
|
|
144
|
+
const t = shortValue(input?.file_path || input?.path, 140);
|
|
145
|
+
return t ? `Edit ${t}` : 'Edit file';
|
|
146
|
+
}
|
|
147
|
+
case 'Write': {
|
|
148
|
+
const t = shortValue(input?.file_path || input?.path, 140);
|
|
149
|
+
return t ? `Write ${t}` : 'Write file';
|
|
150
|
+
}
|
|
151
|
+
case 'Glob': {
|
|
152
|
+
const p = shortValue(input?.pattern || input?.glob, 120);
|
|
153
|
+
return p ? `List files: ${p}` : 'List files';
|
|
154
|
+
}
|
|
155
|
+
case 'Grep': {
|
|
156
|
+
const p = shortValue(input?.pattern || input?.query, 120);
|
|
157
|
+
return p ? `Search text: ${p}` : 'Search text';
|
|
158
|
+
}
|
|
159
|
+
case 'WebFetch': {
|
|
160
|
+
const u = shortValue(input?.url, 120);
|
|
161
|
+
return u ? `Fetch ${u}` : 'Fetch web page';
|
|
162
|
+
}
|
|
163
|
+
case 'WebSearch': {
|
|
164
|
+
const q = shortValue(input?.query, 120);
|
|
165
|
+
return q ? `Search web: ${q}` : 'Search web';
|
|
166
|
+
}
|
|
167
|
+
case 'TodoWrite': return 'Update plan';
|
|
168
|
+
case 'Task': {
|
|
169
|
+
const p = shortValue(input?.description || input?.prompt, 120);
|
|
170
|
+
return p ? `Run task: ${p}` : 'Run task';
|
|
171
|
+
}
|
|
172
|
+
case 'Bash': {
|
|
173
|
+
if (description)
|
|
174
|
+
return `Run shell: ${description}`;
|
|
175
|
+
const c = shortValue(input?.command, 120);
|
|
176
|
+
return c ? `Run shell: ${c}` : 'Run shell command';
|
|
177
|
+
}
|
|
178
|
+
default: {
|
|
179
|
+
if (description)
|
|
180
|
+
return `${tool}: ${description}`;
|
|
181
|
+
const d = shortValue(input?.file_path || input?.path || input?.command || input?.query || input?.pattern || input?.url, 120);
|
|
182
|
+
return d ? `${tool}: ${d}` : tool;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
export function summarizeClaudeToolResult(tool, block, toolUseResult) {
|
|
187
|
+
const summary = tool?.summary || shortValue(tool?.name || 'Tool', 120) || 'Tool';
|
|
188
|
+
const isError = !!block?.is_error;
|
|
189
|
+
if (isError) {
|
|
190
|
+
const detail = firstNonEmptyLine(toolUseResult?.stderr || toolUseResult?.stdout || block?.content || '');
|
|
191
|
+
return detail ? `${summary} failed: ${shortValue(detail, 120)}` : `${summary} failed`;
|
|
192
|
+
}
|
|
193
|
+
const toolName = tool?.name || '';
|
|
194
|
+
if (toolName === 'Read' || toolName === 'Edit' || toolName === 'Write' || toolName === 'TodoWrite')
|
|
195
|
+
return `${summary} done`;
|
|
196
|
+
const detail = firstNonEmptyLine(toolUseResult?.stdout || block?.content || toolUseResult?.stderr || '');
|
|
197
|
+
if (!detail)
|
|
198
|
+
return `${summary} done`;
|
|
199
|
+
return `${summary} -> ${shortValue(detail, 120)}`;
|
|
200
|
+
}
|
|
201
|
+
// Usage helpers (used by drivers)
|
|
202
|
+
export function roundPercent(value) {
|
|
203
|
+
const n = Number(value);
|
|
204
|
+
if (!Number.isFinite(n))
|
|
205
|
+
return null;
|
|
206
|
+
return Math.max(0, Math.min(100, Math.round(n * 10) / 10));
|
|
207
|
+
}
|
|
208
|
+
export function toIsoFromEpochSeconds(value) {
|
|
209
|
+
const n = Number(value);
|
|
210
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
211
|
+
return null;
|
|
212
|
+
return new Date(n * 1000).toISOString();
|
|
213
|
+
}
|
|
214
|
+
export function normalizeUsageStatus(value) {
|
|
215
|
+
const raw = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
216
|
+
if (!raw)
|
|
217
|
+
return null;
|
|
218
|
+
const normalized = raw.replace(/[\s-]+/g, '_');
|
|
219
|
+
if (normalized === 'limit_reached' || normalized === 'warning' || normalized === 'allowed')
|
|
220
|
+
return normalized;
|
|
221
|
+
if (normalized.includes('limit') || normalized.includes('exceeded') || normalized.includes('denied'))
|
|
222
|
+
return 'limit_reached';
|
|
223
|
+
if (normalized.includes('warning') || normalized.includes('warn'))
|
|
224
|
+
return 'warning';
|
|
225
|
+
if (normalized.includes('allowed') || normalized === 'ok' || normalized === 'healthy' || normalized === 'ready')
|
|
226
|
+
return 'allowed';
|
|
227
|
+
return normalized;
|
|
228
|
+
}
|
|
229
|
+
export function labelFromWindowMinutes(value, fallback) {
|
|
230
|
+
const minutes = Number(value);
|
|
231
|
+
if (!Number.isFinite(minutes) || minutes <= 0)
|
|
232
|
+
return fallback;
|
|
233
|
+
const roundedMinutes = Math.round(minutes);
|
|
234
|
+
if (Math.abs(roundedMinutes - 300) <= 2)
|
|
235
|
+
return '5h';
|
|
236
|
+
if (Math.abs(roundedMinutes - 10080) <= 5)
|
|
237
|
+
return '7d';
|
|
238
|
+
const roundedDays = Math.round(roundedMinutes / 1440);
|
|
239
|
+
if (roundedDays >= 1 && Math.abs(roundedMinutes - roundedDays * 1440) <= 5)
|
|
240
|
+
return `${roundedDays}d`;
|
|
241
|
+
const roundedHours = Math.round(roundedMinutes / 60);
|
|
242
|
+
if (roundedHours >= 1 && Math.abs(roundedMinutes - roundedHours * 60) <= 2)
|
|
243
|
+
return `${roundedHours}h`;
|
|
244
|
+
return `${roundedMinutes}m`;
|
|
245
|
+
}
|
|
246
|
+
export function usageWindowFromRateLimit(fallback, limit) {
|
|
247
|
+
if (!limit || typeof limit !== 'object')
|
|
248
|
+
return null;
|
|
249
|
+
const usedPercent = roundPercent(limit.used_percent);
|
|
250
|
+
const remainingPercent = usedPercent == null ? null : Math.max(0, Math.round((100 - usedPercent) * 10) / 10);
|
|
251
|
+
const resetAt = toIsoFromEpochSeconds(limit.reset_at ?? limit.resets_at);
|
|
252
|
+
let resetAfterSeconds = null;
|
|
253
|
+
const directResetAfter = Number(limit.reset_after_seconds);
|
|
254
|
+
if (Number.isFinite(directResetAfter) && directResetAfter >= 0)
|
|
255
|
+
resetAfterSeconds = Math.round(directResetAfter);
|
|
256
|
+
else if (resetAt) {
|
|
257
|
+
const resetAtMs = Date.parse(resetAt);
|
|
258
|
+
if (Number.isFinite(resetAtMs))
|
|
259
|
+
resetAfterSeconds = Math.max(0, Math.round((resetAtMs - Date.now()) / 1000));
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
label: labelFromWindowMinutes(limit.window_minutes, fallback),
|
|
263
|
+
usedPercent, remainingPercent, resetAt, resetAfterSeconds,
|
|
264
|
+
status: normalizeUsageStatus(limit.status),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
export function parseJsonTail(raw) {
|
|
268
|
+
const start = raw.indexOf('{');
|
|
269
|
+
if (start < 0)
|
|
270
|
+
return null;
|
|
271
|
+
try {
|
|
272
|
+
return JSON.parse(raw.slice(start));
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
export function modelFamily(model) {
|
|
279
|
+
const lower = model?.toLowerCase() || '';
|
|
280
|
+
if (!lower)
|
|
281
|
+
return null;
|
|
282
|
+
if (lower.includes('opus'))
|
|
283
|
+
return 'opus';
|
|
284
|
+
if (lower.includes('sonnet'))
|
|
285
|
+
return 'sonnet';
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
export function emptyUsage(agent, error) {
|
|
289
|
+
return { ok: false, agent, source: null, capturedAt: null, status: null, windows: [], error };
|
|
290
|
+
}
|
|
291
|
+
function isExecutableFile(filePath) {
|
|
292
|
+
try {
|
|
293
|
+
const stat = fs.statSync(filePath);
|
|
294
|
+
if (!stat.isFile())
|
|
295
|
+
return false;
|
|
296
|
+
if (process.platform === 'win32')
|
|
297
|
+
return true;
|
|
298
|
+
fs.accessSync(filePath, fs.constants.X_OK);
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function executableCandidates(cmd) {
|
|
306
|
+
if (process.platform !== 'win32')
|
|
307
|
+
return [cmd];
|
|
308
|
+
const ext = path.extname(cmd).toLowerCase();
|
|
309
|
+
if (ext)
|
|
310
|
+
return [cmd];
|
|
311
|
+
const pathExt = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD')
|
|
312
|
+
.split(';')
|
|
313
|
+
.map(value => value.trim())
|
|
314
|
+
.filter(Boolean);
|
|
315
|
+
return [cmd, ...pathExt.map(value => `${cmd}${value.toLowerCase()}`)];
|
|
316
|
+
}
|
|
317
|
+
function resolveAgentBinPath(cmd) {
|
|
318
|
+
const raw = String(cmd || '').trim();
|
|
319
|
+
if (!raw)
|
|
320
|
+
return null;
|
|
321
|
+
const hasPathSeparator = raw.includes('/') || raw.includes('\\');
|
|
322
|
+
if (hasPathSeparator) {
|
|
323
|
+
const absolutePath = path.resolve(raw);
|
|
324
|
+
for (const candidate of executableCandidates(absolutePath)) {
|
|
325
|
+
if (isExecutableFile(candidate))
|
|
326
|
+
return candidate;
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
const searchPaths = String(process.env.PATH || '')
|
|
331
|
+
.split(path.delimiter)
|
|
332
|
+
.map(entry => entry.trim())
|
|
333
|
+
.filter(Boolean);
|
|
334
|
+
for (const dir of searchPaths) {
|
|
335
|
+
for (const candidate of executableCandidates(path.join(dir, raw))) {
|
|
336
|
+
if (isExecutableFile(candidate))
|
|
337
|
+
return candidate;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
function readAgentVersion(binPath, timeoutMs) {
|
|
343
|
+
try {
|
|
344
|
+
return execSync(`${Q(binPath)} --version 2>/dev/null`, {
|
|
345
|
+
encoding: 'utf-8',
|
|
346
|
+
timeout: Math.max(250, timeoutMs),
|
|
347
|
+
}).trim().split('\n')[0] || null;
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// Agent detection (used by all drivers)
|
|
354
|
+
export function detectAgentBin(cmd, agent, options = {}) {
|
|
355
|
+
const cacheKey = `${agent}:${cmd}`;
|
|
356
|
+
const now = Date.now();
|
|
357
|
+
const includeVersion = !!options.includeVersion;
|
|
358
|
+
const refresh = !!options.refresh;
|
|
359
|
+
const versionTimeoutMs = options.versionTimeoutMs ?? AGENT_VERSION_TIMEOUT_MS;
|
|
360
|
+
let entry = agentDetectCache.get(cacheKey) || null;
|
|
361
|
+
const shouldRefreshBase = refresh || !entry || now - entry.detectedAt > AGENT_DETECT_TTL_MS;
|
|
362
|
+
if (shouldRefreshBase) {
|
|
363
|
+
const binPath = resolveAgentBinPath(cmd);
|
|
364
|
+
const previousVersion = entry?.info.path === binPath ? entry.info.version ?? null : null;
|
|
365
|
+
const previousVersionAt = entry?.info.path === binPath ? entry.versionAt : 0;
|
|
366
|
+
entry = {
|
|
367
|
+
detectedAt: now,
|
|
368
|
+
versionAt: previousVersionAt,
|
|
369
|
+
info: {
|
|
370
|
+
agent,
|
|
371
|
+
installed: !!binPath,
|
|
372
|
+
path: binPath,
|
|
373
|
+
version: previousVersion,
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
agentDetectCache.set(cacheKey, entry);
|
|
377
|
+
}
|
|
378
|
+
if (!entry) {
|
|
379
|
+
return { agent, installed: false, path: null, version: null };
|
|
380
|
+
}
|
|
381
|
+
if (includeVersion
|
|
382
|
+
&& entry.info.installed
|
|
383
|
+
&& entry.info.path
|
|
384
|
+
&& (refresh || !entry.versionAt || now - entry.versionAt > AGENT_VERSION_TTL_MS)) {
|
|
385
|
+
entry.info.version = readAgentVersion(entry.info.path, versionTimeoutMs);
|
|
386
|
+
entry.versionAt = now;
|
|
387
|
+
agentDetectCache.set(cacheKey, entry);
|
|
388
|
+
}
|
|
389
|
+
return { ...entry.info };
|
|
390
|
+
}
|
|
391
|
+
// Session tail helpers (used by drivers)
|
|
392
|
+
export function readTailLines(filePath, maxBytes = 256 * 1024) {
|
|
393
|
+
try {
|
|
394
|
+
const stat = fs.statSync(filePath);
|
|
395
|
+
const readSize = Math.min(maxBytes, stat.size);
|
|
396
|
+
const fd = fs.openSync(filePath, 'r');
|
|
397
|
+
const buf = Buffer.alloc(readSize);
|
|
398
|
+
fs.readSync(fd, buf, 0, readSize, stat.size - readSize);
|
|
399
|
+
fs.closeSync(fd);
|
|
400
|
+
return buf.toString('utf-8').split('\n').filter(l => l.trim());
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
return [];
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
export function stripInjectedPrompts(text) {
|
|
407
|
+
const markers = ['\n[Session Workspace]'];
|
|
408
|
+
for (const m of markers) {
|
|
409
|
+
const idx = text.indexOf(m);
|
|
410
|
+
if (idx >= 0)
|
|
411
|
+
return text.slice(0, idx).trim();
|
|
412
|
+
}
|
|
413
|
+
return text;
|
|
414
|
+
}
|
|
415
|
+
const PIKICLAW_DIR = '.pikiclaw';
|
|
416
|
+
const PIKICLAW_SESSIONS_DIR = path.join(PIKICLAW_DIR, 'sessions');
|
|
417
|
+
const PIKICLAW_SESSION_INDEX = path.join(PIKICLAW_SESSIONS_DIR, 'index.json');
|
|
418
|
+
const PIKICLAW_LEGACY_WORKSPACES_DIR = path.join(PIKICLAW_DIR, 'workspaces');
|
|
419
|
+
const SESSION_WORKSPACE_DIR = 'workspace';
|
|
420
|
+
const SESSION_META_FILE = 'session.json';
|
|
421
|
+
// return.json and artifact constants removed — file return is now handled by MCP bridge
|
|
422
|
+
function sessionIndexPath(workdir) { return path.join(workdir, PIKICLAW_SESSION_INDEX); }
|
|
423
|
+
function sessionDirPath(workdir, agent, sessionId) { return path.join(workdir, PIKICLAW_SESSIONS_DIR, agent, sessionId); }
|
|
424
|
+
function legacySessionWorkspacePath(workdir, agent, sessionId) { return path.join(workdir, PIKICLAW_LEGACY_WORKSPACES_DIR, agent, sessionId); }
|
|
425
|
+
function sessionWorkspacePath(workdir, agent, sessionId) { return path.join(sessionDirPath(workdir, agent, sessionId), SESSION_WORKSPACE_DIR); }
|
|
426
|
+
function sessionRootFromWorkspacePath(workspacePath) {
|
|
427
|
+
const resolved = path.resolve(workspacePath);
|
|
428
|
+
return path.basename(resolved) === SESSION_WORKSPACE_DIR ? path.dirname(resolved) : resolved;
|
|
429
|
+
}
|
|
430
|
+
function sessionMetaPath(workspacePath) { return path.join(sessionRootFromWorkspacePath(workspacePath), SESSION_META_FILE); }
|
|
431
|
+
function legacySessionMetaPath(workspacePath) { return path.join(workspacePath, PIKICLAW_DIR, SESSION_META_FILE); }
|
|
432
|
+
/** Generate a temporary session ID for new sessions before the agent assigns one. */
|
|
433
|
+
function nextPendingSessionId() { return `pending_${crypto.randomBytes(6).toString('hex')}`; }
|
|
434
|
+
export function isPendingSessionId(sessionId) {
|
|
435
|
+
return typeof sessionId === 'string' && sessionId.startsWith('pending_');
|
|
436
|
+
}
|
|
437
|
+
function normalizeSessionRecord(raw, workdir) {
|
|
438
|
+
// Support both new format (sessionId) and legacy format (localSessionId + engineSessionId)
|
|
439
|
+
const sessionId = typeof raw?.sessionId === 'string' ? raw.sessionId.trim()
|
|
440
|
+
: typeof raw?.engineSessionId === 'string' && raw.engineSessionId.trim() ? raw.engineSessionId.trim()
|
|
441
|
+
: typeof raw?.localSessionId === 'string' ? raw.localSessionId.trim()
|
|
442
|
+
: '';
|
|
443
|
+
const agent = typeof raw?.agent === 'string' ? raw.agent.trim() : null;
|
|
444
|
+
if (!sessionId || !agent)
|
|
445
|
+
return null;
|
|
446
|
+
const workspacePath = typeof raw?.workspacePath === 'string' && raw.workspacePath.trim()
|
|
447
|
+
? path.resolve(raw.workspacePath)
|
|
448
|
+
: sessionWorkspacePath(workdir, agent, sessionId);
|
|
449
|
+
return {
|
|
450
|
+
sessionId, agent, workdir,
|
|
451
|
+
workspacePath,
|
|
452
|
+
createdAt: typeof raw?.createdAt === 'string' && raw.createdAt.trim() ? raw.createdAt : new Date().toISOString(),
|
|
453
|
+
updatedAt: typeof raw?.updatedAt === 'string' && raw.updatedAt.trim() ? raw.updatedAt : new Date().toISOString(),
|
|
454
|
+
title: typeof raw?.title === 'string' && raw.title.trim() ? raw.title.trim() : null,
|
|
455
|
+
model: typeof raw?.model === 'string' && raw.model.trim() ? raw.model.trim() : null,
|
|
456
|
+
stagedFiles: Array.isArray(raw?.stagedFiles) ? dedupeStrings(raw.stagedFiles.filter((v) => typeof v === 'string')) : [],
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
function loadSessionIndex(workdir) {
|
|
460
|
+
const parsed = readJsonFile(sessionIndexPath(workdir), { version: 1, sessions: [] });
|
|
461
|
+
const sessions = Array.isArray(parsed?.sessions) ? parsed.sessions : [];
|
|
462
|
+
return {
|
|
463
|
+
version: 1,
|
|
464
|
+
sessions: sessions
|
|
465
|
+
.map((entry) => normalizeSessionRecord(entry, workdir))
|
|
466
|
+
.filter((entry) => !!entry),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
function writeSessionMeta(record) {
|
|
470
|
+
writeJsonFile(sessionMetaPath(record.workspacePath), {
|
|
471
|
+
sessionId: record.sessionId, agent: record.agent, workdir: record.workdir,
|
|
472
|
+
workspacePath: record.workspacePath,
|
|
473
|
+
createdAt: record.createdAt, updatedAt: record.updatedAt,
|
|
474
|
+
title: record.title, model: record.model, stagedFiles: record.stagedFiles,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
function copyPath(sourcePath, targetPath) {
|
|
478
|
+
const stat = fs.statSync(sourcePath);
|
|
479
|
+
if (stat.isDirectory()) {
|
|
480
|
+
fs.cpSync(sourcePath, targetPath, { recursive: true, force: true });
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
ensureDir(path.dirname(targetPath));
|
|
484
|
+
fs.copyFileSync(sourcePath, targetPath);
|
|
485
|
+
}
|
|
486
|
+
function migrateSessionLayout(workdir, record) {
|
|
487
|
+
const targetSessionDir = sessionDirPath(workdir, record.agent, record.sessionId);
|
|
488
|
+
const targetWorkspacePath = sessionWorkspacePath(workdir, record.agent, record.sessionId);
|
|
489
|
+
const currentWorkspacePath = path.resolve(record.workspacePath || targetWorkspacePath);
|
|
490
|
+
const legacyWp = path.resolve(legacySessionWorkspacePath(workdir, record.agent, record.sessionId));
|
|
491
|
+
ensureDir(targetSessionDir);
|
|
492
|
+
ensureDir(targetWorkspacePath);
|
|
493
|
+
for (const sourceWorkspacePath of dedupeStrings([currentWorkspacePath, legacyWp])) {
|
|
494
|
+
if (sourceWorkspacePath === targetWorkspacePath || !fs.existsSync(sourceWorkspacePath))
|
|
495
|
+
continue;
|
|
496
|
+
if (!fs.statSync(sourceWorkspacePath).isDirectory())
|
|
497
|
+
continue;
|
|
498
|
+
for (const entry of fs.readdirSync(sourceWorkspacePath)) {
|
|
499
|
+
if (entry === PIKICLAW_DIR)
|
|
500
|
+
continue;
|
|
501
|
+
copyPath(path.join(sourceWorkspacePath, entry), path.join(targetWorkspacePath, entry));
|
|
502
|
+
}
|
|
503
|
+
if (sourceWorkspacePath === legacyWp)
|
|
504
|
+
fs.rmSync(sourceWorkspacePath, { recursive: true, force: true });
|
|
505
|
+
}
|
|
506
|
+
record.workspacePath = path.resolve(targetWorkspacePath);
|
|
507
|
+
return record;
|
|
508
|
+
}
|
|
509
|
+
function saveSessionRecord(workdir, record) {
|
|
510
|
+
record = migrateSessionLayout(workdir, record);
|
|
511
|
+
ensureDir(sessionDirPath(workdir, record.agent, record.sessionId));
|
|
512
|
+
ensureDir(record.workspacePath);
|
|
513
|
+
const index = loadSessionIndex(workdir);
|
|
514
|
+
record.updatedAt = new Date().toISOString();
|
|
515
|
+
const pos = index.sessions.findIndex(entry => entry.sessionId === record.sessionId);
|
|
516
|
+
if (pos >= 0)
|
|
517
|
+
index.sessions[pos] = record;
|
|
518
|
+
else
|
|
519
|
+
index.sessions.unshift(record);
|
|
520
|
+
index.sessions.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
|
521
|
+
writeJsonFile(sessionIndexPath(workdir), { version: 1, sessions: index.sessions });
|
|
522
|
+
writeSessionMeta(record);
|
|
523
|
+
return record;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Promote a pending session to a real session ID. Renames the workspace directory
|
|
527
|
+
* and updates the index. Called after the first stream returns the agent's native ID.
|
|
528
|
+
*/
|
|
529
|
+
export function promoteSessionId(workdir, agent, pendingId, nativeId) {
|
|
530
|
+
if (!isPendingSessionId(pendingId) || !nativeId.trim())
|
|
531
|
+
return;
|
|
532
|
+
const resolvedWorkdir = path.resolve(workdir);
|
|
533
|
+
const index = loadSessionIndex(resolvedWorkdir);
|
|
534
|
+
const record = index.sessions.find(entry => entry.sessionId === pendingId && entry.agent === agent);
|
|
535
|
+
if (!record)
|
|
536
|
+
return;
|
|
537
|
+
const oldDir = sessionDirPath(resolvedWorkdir, agent, pendingId);
|
|
538
|
+
const newDir = sessionDirPath(resolvedWorkdir, agent, nativeId);
|
|
539
|
+
// Move workspace directory if it exists
|
|
540
|
+
if (fs.existsSync(oldDir) && !fs.existsSync(newDir)) {
|
|
541
|
+
try {
|
|
542
|
+
fs.renameSync(oldDir, newDir);
|
|
543
|
+
}
|
|
544
|
+
catch { /* cross-device: copy+delete */
|
|
545
|
+
try {
|
|
546
|
+
fs.cpSync(oldDir, newDir, { recursive: true });
|
|
547
|
+
fs.rmSync(oldDir, { recursive: true, force: true });
|
|
548
|
+
}
|
|
549
|
+
catch { }
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
record.sessionId = nativeId;
|
|
553
|
+
record.workspacePath = sessionWorkspacePath(resolvedWorkdir, agent, nativeId);
|
|
554
|
+
saveSessionRecord(resolvedWorkdir, record);
|
|
555
|
+
}
|
|
556
|
+
function summarizePromptTitle(prompt) {
|
|
557
|
+
const text = String(prompt || '').replace(/\s+/g, ' ').trim();
|
|
558
|
+
if (!text)
|
|
559
|
+
return null;
|
|
560
|
+
return text.length <= 120 ? text : `${text.slice(0, 117).trimEnd()}...`;
|
|
561
|
+
}
|
|
562
|
+
function safeWorkspaceFilename(filename) {
|
|
563
|
+
const base = path.basename(filename || 'file');
|
|
564
|
+
const sanitized = base.replace(/[^\w.\- ]+/g, '_').replace(/^\.+/, '').trim();
|
|
565
|
+
return sanitized || `file-${Date.now()}`;
|
|
566
|
+
}
|
|
567
|
+
function uniqueWorkspaceFilename(workspacePath, desiredName) {
|
|
568
|
+
const ext = path.extname(desiredName);
|
|
569
|
+
const stem = ext ? desiredName.slice(0, -ext.length) : desiredName;
|
|
570
|
+
let candidate = desiredName;
|
|
571
|
+
let index = 2;
|
|
572
|
+
while (fs.existsSync(path.join(workspacePath, candidate))) {
|
|
573
|
+
candidate = `${stem}-${index}${ext}`;
|
|
574
|
+
index++;
|
|
575
|
+
}
|
|
576
|
+
return candidate;
|
|
577
|
+
}
|
|
578
|
+
function importFilesIntoWorkspace(workspacePath, files) {
|
|
579
|
+
const imported = [];
|
|
580
|
+
const realWorkspace = fs.realpathSync(workspacePath);
|
|
581
|
+
for (const filePath of files) {
|
|
582
|
+
const sourcePath = path.resolve(filePath);
|
|
583
|
+
if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile())
|
|
584
|
+
continue;
|
|
585
|
+
let relPath = path.relative(realWorkspace, sourcePath);
|
|
586
|
+
if (relPath && !relPath.startsWith('..') && !path.isAbsolute(relPath)) {
|
|
587
|
+
imported.push(relPath.split(path.sep).join(path.posix.sep));
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
const targetName = uniqueWorkspaceFilename(workspacePath, safeWorkspaceFilename(path.basename(sourcePath)));
|
|
591
|
+
fs.copyFileSync(sourcePath, path.join(workspacePath, targetName));
|
|
592
|
+
imported.push(targetName);
|
|
593
|
+
}
|
|
594
|
+
return dedupeStrings(imported);
|
|
595
|
+
}
|
|
596
|
+
function ensureSessionWorkspace(opts) {
|
|
597
|
+
const workdir = path.resolve(opts.workdir);
|
|
598
|
+
const index = loadSessionIndex(workdir);
|
|
599
|
+
let record = index.sessions.find(entry => entry.agent === opts.agent && opts.sessionId && entry.sessionId === opts.sessionId)
|
|
600
|
+
|| null;
|
|
601
|
+
if (!record) {
|
|
602
|
+
const sessionId = opts.sessionId?.trim() || nextPendingSessionId();
|
|
603
|
+
record = {
|
|
604
|
+
sessionId, agent: opts.agent, workdir,
|
|
605
|
+
workspacePath: sessionWorkspacePath(workdir, opts.agent, sessionId),
|
|
606
|
+
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
607
|
+
title: summarizePromptTitle(opts.title) || null, model: null, stagedFiles: [],
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
if (!record.title && opts.title)
|
|
611
|
+
record.title = summarizePromptTitle(opts.title);
|
|
612
|
+
record.workspacePath = path.resolve(record.workspacePath);
|
|
613
|
+
saveSessionRecord(workdir, record);
|
|
614
|
+
return { sessionId: record.sessionId, workspacePath: record.workspacePath, record };
|
|
615
|
+
}
|
|
616
|
+
// Exported for drivers
|
|
617
|
+
export function listPikiclawSessions(workdir, agent, limit) {
|
|
618
|
+
const records = loadSessionIndex(path.resolve(workdir)).sessions
|
|
619
|
+
.filter(entry => entry.agent === agent)
|
|
620
|
+
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
|
621
|
+
return typeof limit === 'number' ? records.slice(0, limit) : records;
|
|
622
|
+
}
|
|
623
|
+
export function findPikiclawSession(workdir, agent, sessionId) {
|
|
624
|
+
return listPikiclawSessions(workdir, agent).find(entry => entry.sessionId === sessionId) || null;
|
|
625
|
+
}
|
|
626
|
+
export function stageSessionFiles(opts) {
|
|
627
|
+
const session = ensureSessionWorkspace({ agent: opts.agent, workdir: opts.workdir, sessionId: opts.sessionId, title: opts.title });
|
|
628
|
+
const importedFiles = importFilesIntoWorkspace(session.workspacePath, opts.files);
|
|
629
|
+
if (importedFiles.length) {
|
|
630
|
+
session.record.stagedFiles = dedupeStrings([...session.record.stagedFiles, ...importedFiles]);
|
|
631
|
+
if (!session.record.title)
|
|
632
|
+
session.record.title = importedFiles[0];
|
|
633
|
+
saveSessionRecord(opts.workdir, session.record);
|
|
634
|
+
}
|
|
635
|
+
return { sessionId: session.sessionId, workspacePath: session.workspacePath, importedFiles };
|
|
636
|
+
}
|
|
637
|
+
// ---------------------------------------------------------------------------
|
|
638
|
+
// Shared CLI spawn framework (used by driver-claude.ts, driver-gemini.ts)
|
|
639
|
+
// ---------------------------------------------------------------------------
|
|
640
|
+
export async function run(cmd, opts, parseLine) {
|
|
641
|
+
const start = Date.now();
|
|
642
|
+
const deadline = start + opts.timeout * 1000;
|
|
643
|
+
let stderr = '';
|
|
644
|
+
let lineCount = 0;
|
|
645
|
+
let timedOut = false;
|
|
646
|
+
const s = {
|
|
647
|
+
sessionId: opts.sessionId, text: '', thinking: '', msgs: [], thinkParts: [],
|
|
648
|
+
model: opts.model, thinkingEffort: opts.thinkingEffort, errors: null,
|
|
649
|
+
inputTokens: null, outputTokens: null, cachedInputTokens: null,
|
|
650
|
+
cacheCreationInputTokens: null, contextWindow: null,
|
|
651
|
+
codexCumulative: null,
|
|
652
|
+
stopReason: null, activity: '',
|
|
653
|
+
recentActivity: [],
|
|
654
|
+
claudeToolsById: new Map(),
|
|
655
|
+
seenClaudeToolIds: new Set(),
|
|
656
|
+
};
|
|
657
|
+
const shellCmd = cmd.map(Q).join(' ');
|
|
658
|
+
agentLog(`[spawn] full command: cd ${Q(opts.workdir)} && ${shellCmd}`);
|
|
659
|
+
agentLog(`[spawn] timeout: ${opts.timeout}s session: ${opts.sessionId || '(new)'}`);
|
|
660
|
+
agentLog(`[spawn] prompt (stdin): "${opts.prompt.slice(0, 300)}${opts.prompt.length > 300 ? '…' : ''}"`);
|
|
661
|
+
const proc = spawn(shellCmd, {
|
|
662
|
+
cwd: opts.workdir,
|
|
663
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
664
|
+
shell: true,
|
|
665
|
+
detached: process.platform !== 'win32',
|
|
666
|
+
});
|
|
667
|
+
agentLog(`[spawn] pid=${proc.pid}`);
|
|
668
|
+
try {
|
|
669
|
+
proc.stdin.write(opts._stdinOverride ?? opts.prompt);
|
|
670
|
+
proc.stdin.end();
|
|
671
|
+
}
|
|
672
|
+
catch { }
|
|
673
|
+
proc.stderr?.on('data', (c) => { const chunk = c.toString(); stderr += chunk; agentLog(`[stderr] ${chunk.trim().slice(0, 200)}`); });
|
|
674
|
+
const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
|
|
675
|
+
rl.on('line', raw => {
|
|
676
|
+
if (Date.now() > deadline) {
|
|
677
|
+
timedOut = true;
|
|
678
|
+
s.stopReason = 'timeout';
|
|
679
|
+
agentLog(`[timeout] deadline exceeded, killing process tree`);
|
|
680
|
+
terminateProcessTree(proc, { signal: 'SIGKILL' });
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const line = raw.trim();
|
|
684
|
+
if (!line || line[0] !== '{')
|
|
685
|
+
return;
|
|
686
|
+
lineCount++;
|
|
687
|
+
try {
|
|
688
|
+
const ev = JSON.parse(line);
|
|
689
|
+
const evType = ev.type || '?';
|
|
690
|
+
if (evType === 'system' || evType === 'result' || evType === 'assistant' || evType === 'thread.started' || evType === 'turn.completed' || evType === 'item.completed') {
|
|
691
|
+
agentLog(`[event] type=${evType} session=${ev.session_id || s.sessionId || '?'} model=${ev.model || s.model || '?'}`);
|
|
692
|
+
}
|
|
693
|
+
if (evType === 'stream_event') {
|
|
694
|
+
const inner = ev.event || {};
|
|
695
|
+
if (inner.type === 'message_start' || inner.type === 'message_delta')
|
|
696
|
+
agentLog(`[event] stream_event/${inner.type} session=${ev.session_id || '?'}`);
|
|
697
|
+
}
|
|
698
|
+
parseLine(ev, s);
|
|
699
|
+
opts.onText(s.text, s.thinking, s.activity, buildStreamPreviewMeta(s), null);
|
|
700
|
+
}
|
|
701
|
+
catch { }
|
|
702
|
+
});
|
|
703
|
+
const hardTimer = setTimeout(() => {
|
|
704
|
+
timedOut = true;
|
|
705
|
+
s.stopReason = 'timeout';
|
|
706
|
+
agentLog(`[timeout] hard deadline reached (${opts.timeout}s), killing process tree pid=${proc.pid}`);
|
|
707
|
+
terminateProcessTree(proc, { signal: 'SIGTERM', forceSignal: 'SIGKILL', forceAfterMs: 5000 });
|
|
708
|
+
}, opts.timeout * 1000 + 10_000);
|
|
709
|
+
const [procOk, code] = await new Promise(resolve => {
|
|
710
|
+
proc.on('close', code => { clearTimeout(hardTimer); agentLog(`[exit] code=${code} lines_parsed=${lineCount}`); resolve([code === 0, code]); });
|
|
711
|
+
proc.on('error', e => { clearTimeout(hardTimer); agentLog(`[error] ${e.message}`); stderr += e.message; resolve([false, -1]); });
|
|
712
|
+
});
|
|
713
|
+
if (!s.text.trim() && s.msgs.length)
|
|
714
|
+
s.text = s.msgs.join('\n\n');
|
|
715
|
+
if (!s.thinking.trim() && s.thinkParts.length)
|
|
716
|
+
s.thinking = s.thinkParts.join('\n\n');
|
|
717
|
+
const ok = procOk && !s.errors && !timedOut;
|
|
718
|
+
const error = s.errors?.map(e => e.trim()).filter(Boolean).join('; ').trim()
|
|
719
|
+
|| (timedOut ? `Timed out after ${opts.timeout}s before the agent reported completion.` : null)
|
|
720
|
+
|| (!procOk ? (stderr.trim() || `Failed (exit=${code}).`) : null);
|
|
721
|
+
const incomplete = !ok || s.stopReason === 'max_tokens' || s.stopReason === 'timeout';
|
|
722
|
+
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
723
|
+
agentLog(`[result] ok=${ok && !s.errors} elapsed=${elapsed}s text=${s.text.length}chars thinking=${s.thinking.length}chars session=${s.sessionId || '?'}`);
|
|
724
|
+
if (s.errors)
|
|
725
|
+
agentLog(`[result] errors: ${s.errors.join('; ')}`);
|
|
726
|
+
if (s.stopReason)
|
|
727
|
+
agentLog(`[result] stop_reason=${s.stopReason}`);
|
|
728
|
+
if (stderr.trim() && !procOk)
|
|
729
|
+
agentLog(`[result] stderr: ${stderr.trim().slice(0, 300)}`);
|
|
730
|
+
return {
|
|
731
|
+
ok, sessionId: s.sessionId, workspacePath: null,
|
|
732
|
+
model: s.model, thinkingEffort: s.thinkingEffort,
|
|
733
|
+
message: s.text.trim() || s.errors?.join('; ') || (procOk ? '(no textual response)' : `Failed (exit=${code}).\n\n${stderr.trim() || '(no output)'}`),
|
|
734
|
+
thinking: s.thinking.trim() || null,
|
|
735
|
+
elapsedS: (Date.now() - start) / 1000,
|
|
736
|
+
inputTokens: s.inputTokens, outputTokens: s.outputTokens, cachedInputTokens: s.cachedInputTokens,
|
|
737
|
+
cacheCreationInputTokens: s.cacheCreationInputTokens, contextWindow: s.contextWindow,
|
|
738
|
+
...computeContext(s), codexCumulative: s.codexCumulative, error, stopReason: s.stopReason,
|
|
739
|
+
incomplete, activity: s.activity.trim() || null,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
// ---------------------------------------------------------------------------
|
|
743
|
+
// Unified entry points (delegate to drivers via registry)
|
|
744
|
+
// ---------------------------------------------------------------------------
|
|
745
|
+
function prepareStreamOpts(opts) {
|
|
746
|
+
const session = ensureSessionWorkspace({ agent: opts.agent, workdir: opts.workdir, sessionId: opts.sessionId, title: opts.prompt });
|
|
747
|
+
const importedFiles = importFilesIntoWorkspace(session.workspacePath, opts.attachments || []);
|
|
748
|
+
const attachmentRelPaths = dedupeStrings([...session.record.stagedFiles, ...importedFiles]);
|
|
749
|
+
// Capture staged files for MCP bridge before clearing
|
|
750
|
+
const stagedFiles = [...session.record.stagedFiles];
|
|
751
|
+
session.record.stagedFiles = [];
|
|
752
|
+
if (!session.record.title)
|
|
753
|
+
session.record.title = summarizePromptTitle(opts.prompt) || importedFiles[0] || null;
|
|
754
|
+
saveSessionRecord(opts.workdir, session.record);
|
|
755
|
+
const attachmentPaths = attachmentRelPaths.map(relPath => path.join(session.workspacePath, relPath));
|
|
756
|
+
// For pending sessions, pass null sessionId to the CLI so it creates a new session
|
|
757
|
+
const effectiveSessionId = isPendingSessionId(session.sessionId) ? null : session.sessionId;
|
|
758
|
+
return {
|
|
759
|
+
session,
|
|
760
|
+
attachments: attachmentPaths,
|
|
761
|
+
stagedFiles,
|
|
762
|
+
prepared: {
|
|
763
|
+
...opts,
|
|
764
|
+
sessionId: effectiveSessionId,
|
|
765
|
+
attachments: attachmentPaths.length ? attachmentPaths : undefined,
|
|
766
|
+
},
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
function finalizeStreamResult(result, workdir, prompt, session) {
|
|
770
|
+
// If the agent returned a native session ID and our session was pending, promote it
|
|
771
|
+
const pendingId = session.sessionId;
|
|
772
|
+
if (result.sessionId && isPendingSessionId(pendingId)) {
|
|
773
|
+
promoteSessionId(workdir, session.record.agent, pendingId, result.sessionId);
|
|
774
|
+
session.sessionId = result.sessionId;
|
|
775
|
+
}
|
|
776
|
+
session.record.sessionId = result.sessionId || session.record.sessionId;
|
|
777
|
+
session.record.model = result.model || session.record.model;
|
|
778
|
+
if (!session.record.title)
|
|
779
|
+
session.record.title = summarizePromptTitle(prompt);
|
|
780
|
+
saveSessionRecord(workdir, session.record);
|
|
781
|
+
return { ...result, sessionId: session.sessionId, workspacePath: session.workspacePath };
|
|
782
|
+
}
|
|
783
|
+
export async function doStream(opts) {
|
|
784
|
+
let session;
|
|
785
|
+
let prepared;
|
|
786
|
+
let stagedFiles;
|
|
787
|
+
try {
|
|
788
|
+
const prep = prepareStreamOpts(opts);
|
|
789
|
+
session = prep.session;
|
|
790
|
+
prepared = prep.prepared;
|
|
791
|
+
stagedFiles = prep.stagedFiles;
|
|
792
|
+
}
|
|
793
|
+
catch (e) {
|
|
794
|
+
const message = e?.message || String(e);
|
|
795
|
+
return {
|
|
796
|
+
ok: false, message, thinking: null,
|
|
797
|
+
sessionId: opts.sessionId, workspacePath: null, model: opts.model, thinkingEffort: opts.thinkingEffort,
|
|
798
|
+
elapsedS: 0, inputTokens: null, outputTokens: null, cachedInputTokens: null,
|
|
799
|
+
cacheCreationInputTokens: null, contextWindow: null, contextUsedTokens: null, contextPercent: null,
|
|
800
|
+
codexCumulative: null, error: message, stopReason: null, incomplete: true, activity: null,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
// Start MCP bridge if sendFile callback is provided
|
|
804
|
+
let bridge = null;
|
|
805
|
+
if (opts.mcpSendFile) {
|
|
806
|
+
try {
|
|
807
|
+
const { startMcpBridge } = await import('./mcp-bridge.js');
|
|
808
|
+
const sessionDir = path.dirname(session.workspacePath);
|
|
809
|
+
bridge = await startMcpBridge({
|
|
810
|
+
sessionDir,
|
|
811
|
+
workspacePath: session.workspacePath,
|
|
812
|
+
workdir: opts.workdir,
|
|
813
|
+
stagedFiles,
|
|
814
|
+
sendFile: opts.mcpSendFile,
|
|
815
|
+
agent: opts.agent,
|
|
816
|
+
});
|
|
817
|
+
prepared.mcpConfigPath = bridge.configPath;
|
|
818
|
+
agentLog(`[mcp] bridge started on ${bridge.configPath}`);
|
|
819
|
+
}
|
|
820
|
+
catch (e) {
|
|
821
|
+
agentLog(`[mcp] bridge start failed: ${e.message} — proceeding without MCP`);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
try {
|
|
825
|
+
const driver = getDriver(prepared.agent);
|
|
826
|
+
const result = await driver.doStream(prepared);
|
|
827
|
+
return finalizeStreamResult(result, opts.workdir, opts.prompt, session);
|
|
828
|
+
}
|
|
829
|
+
finally {
|
|
830
|
+
if (bridge) {
|
|
831
|
+
await bridge.stop().catch(() => { });
|
|
832
|
+
agentLog('[mcp] bridge stopped');
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
export function getSessions(opts) {
|
|
837
|
+
const workdir = path.resolve(opts.workdir);
|
|
838
|
+
agentLog(`[sessions] request agent=${opts.agent} workdir=${workdir} limit=${opts.limit ?? 'all'}`);
|
|
839
|
+
return getDriver(opts.agent).getSessions(workdir, opts.limit).then(result => {
|
|
840
|
+
agentLog(`[sessions] result agent=${opts.agent} ok=${result.ok} count=${result.sessions.length} error=${result.error || '(none)'}`);
|
|
841
|
+
return result;
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
export function getSessionTail(opts) {
|
|
845
|
+
return getDriver(opts.agent).getSessionTail(opts);
|
|
846
|
+
}
|
|
847
|
+
export function listAgents(options = {}) {
|
|
848
|
+
return { agents: allDrivers().map(d => detectAgentBin(d.cmd, d.id, options)) };
|
|
849
|
+
}
|
|
850
|
+
function parseSkillMeta(content) {
|
|
851
|
+
let label = null;
|
|
852
|
+
let description = null;
|
|
853
|
+
const fm = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
854
|
+
if (fm) {
|
|
855
|
+
const lm = fm[1].match(/^label:\s*(.+)/m);
|
|
856
|
+
if (lm)
|
|
857
|
+
label = lm[1].trim();
|
|
858
|
+
const dm = fm[1].match(/^description:\s*(.+)/m);
|
|
859
|
+
if (dm)
|
|
860
|
+
description = dm[1].trim();
|
|
861
|
+
}
|
|
862
|
+
if (!label) {
|
|
863
|
+
const hm = content.match(/^#\s+(.+)$/m);
|
|
864
|
+
if (hm)
|
|
865
|
+
label = hm[1].trim();
|
|
866
|
+
}
|
|
867
|
+
return { label, description };
|
|
868
|
+
}
|
|
869
|
+
function hasFile(filePath) {
|
|
870
|
+
try {
|
|
871
|
+
return fs.statSync(filePath).isFile();
|
|
872
|
+
}
|
|
873
|
+
catch {
|
|
874
|
+
return false;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
function hasDir(dirPath) {
|
|
878
|
+
try {
|
|
879
|
+
return fs.statSync(dirPath).isDirectory();
|
|
880
|
+
}
|
|
881
|
+
catch {
|
|
882
|
+
return false;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
function readSortedDir(dirPath) {
|
|
886
|
+
try {
|
|
887
|
+
return fs.readdirSync(dirPath).sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }));
|
|
888
|
+
}
|
|
889
|
+
catch {
|
|
890
|
+
return [];
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
function listRelativeFiles(dirPath, prefix = '') {
|
|
894
|
+
const files = [];
|
|
895
|
+
for (const entry of readSortedDir(dirPath)) {
|
|
896
|
+
const abs = path.join(dirPath, entry);
|
|
897
|
+
const rel = prefix ? path.join(prefix, entry) : entry;
|
|
898
|
+
let stat;
|
|
899
|
+
try {
|
|
900
|
+
stat = fs.statSync(abs);
|
|
901
|
+
}
|
|
902
|
+
catch {
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
if (stat.isDirectory())
|
|
906
|
+
files.push(...listRelativeFiles(abs, rel));
|
|
907
|
+
else if (stat.isFile())
|
|
908
|
+
files.push(rel);
|
|
909
|
+
}
|
|
910
|
+
return files;
|
|
911
|
+
}
|
|
912
|
+
function listProjectSkillCandidates(rootDir, source) {
|
|
913
|
+
const entries = new Map();
|
|
914
|
+
for (const name of readSortedDir(rootDir)) {
|
|
915
|
+
const dirPath = path.join(rootDir, name);
|
|
916
|
+
const skillFile = path.join(dirPath, 'SKILL.md');
|
|
917
|
+
if (!hasDir(dirPath) || !hasFile(skillFile))
|
|
918
|
+
continue;
|
|
919
|
+
let mtimeMs = 0;
|
|
920
|
+
try {
|
|
921
|
+
mtimeMs = fs.statSync(skillFile).mtimeMs;
|
|
922
|
+
}
|
|
923
|
+
catch { }
|
|
924
|
+
entries.set(name, { source, dirPath, skillFile, mtimeMs });
|
|
925
|
+
}
|
|
926
|
+
return entries;
|
|
927
|
+
}
|
|
928
|
+
function realPathOrNull(filePath) {
|
|
929
|
+
try {
|
|
930
|
+
return fs.realpathSync(filePath);
|
|
931
|
+
}
|
|
932
|
+
catch {
|
|
933
|
+
return null;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
function chooseProjectSkillCandidate(candidates) {
|
|
937
|
+
if (!candidates.length)
|
|
938
|
+
return null;
|
|
939
|
+
const priority = {
|
|
940
|
+
canonical: 0,
|
|
941
|
+
agents: 1,
|
|
942
|
+
claude: 2,
|
|
943
|
+
};
|
|
944
|
+
return [...candidates].sort((a, b) => {
|
|
945
|
+
const byPriority = priority[a.source] - priority[b.source];
|
|
946
|
+
if (byPriority !== 0)
|
|
947
|
+
return byPriority;
|
|
948
|
+
if (b.mtimeMs !== a.mtimeMs)
|
|
949
|
+
return b.mtimeMs - a.mtimeMs;
|
|
950
|
+
return a.dirPath.localeCompare(b.dirPath);
|
|
951
|
+
})[0] || null;
|
|
952
|
+
}
|
|
953
|
+
function replaceDir(srcDir, destDir) {
|
|
954
|
+
try {
|
|
955
|
+
fs.rmSync(destDir, { recursive: true, force: true });
|
|
956
|
+
}
|
|
957
|
+
catch { }
|
|
958
|
+
fs.mkdirSync(path.dirname(destDir), { recursive: true });
|
|
959
|
+
fs.cpSync(srcDir, destDir, { recursive: true });
|
|
960
|
+
}
|
|
961
|
+
function ensureDirSymlink(linkPath, targetDir) {
|
|
962
|
+
const desiredTarget = path.relative(path.dirname(linkPath), targetDir) || '.';
|
|
963
|
+
try {
|
|
964
|
+
const stat = fs.lstatSync(linkPath);
|
|
965
|
+
if (stat.isSymbolicLink()) {
|
|
966
|
+
const currentTarget = fs.readlinkSync(linkPath);
|
|
967
|
+
const currentReal = realPathOrNull(path.resolve(path.dirname(linkPath), currentTarget));
|
|
968
|
+
const desiredReal = realPathOrNull(targetDir);
|
|
969
|
+
if (currentTarget === desiredTarget || (currentReal && desiredReal && currentReal === desiredReal))
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
fs.rmSync(linkPath, { recursive: true, force: true });
|
|
973
|
+
}
|
|
974
|
+
catch { }
|
|
975
|
+
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
|
976
|
+
fs.symlinkSync(desiredTarget, linkPath, 'dir');
|
|
977
|
+
}
|
|
978
|
+
export function initializeProjectSkills(workdir, opts = {}) {
|
|
979
|
+
const canonicalRoot = path.join(workdir, '.pikiclaw', 'skills');
|
|
980
|
+
const agentsRoot = path.join(workdir, '.agents', 'skills');
|
|
981
|
+
const claudeRoot = path.join(workdir, '.claude', 'skills');
|
|
982
|
+
const candidatesByName = new Map();
|
|
983
|
+
const canonicalReal = realPathOrNull(canonicalRoot);
|
|
984
|
+
const roots = [
|
|
985
|
+
{ rootDir: canonicalRoot, source: 'canonical' },
|
|
986
|
+
{ rootDir: agentsRoot, source: 'agents' },
|
|
987
|
+
{ rootDir: claudeRoot, source: 'claude' },
|
|
988
|
+
];
|
|
989
|
+
for (const { rootDir, source } of roots) {
|
|
990
|
+
if (source !== 'canonical') {
|
|
991
|
+
const rootReal = realPathOrNull(rootDir);
|
|
992
|
+
if (rootReal && canonicalReal && rootReal === canonicalReal)
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
for (const [name, candidate] of listProjectSkillCandidates(rootDir, source)) {
|
|
996
|
+
candidatesByName.set(name, [...(candidatesByName.get(name) || []), candidate]);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
fs.mkdirSync(canonicalRoot, { recursive: true });
|
|
1000
|
+
let merged = 0;
|
|
1001
|
+
for (const [name, candidates] of candidatesByName) {
|
|
1002
|
+
const canonicalDir = path.join(canonicalRoot, name);
|
|
1003
|
+
if (hasDir(canonicalDir))
|
|
1004
|
+
continue;
|
|
1005
|
+
const chosen = chooseProjectSkillCandidate(candidates);
|
|
1006
|
+
if (!chosen || chosen.source === 'canonical')
|
|
1007
|
+
continue;
|
|
1008
|
+
replaceDir(chosen.dirPath, canonicalDir);
|
|
1009
|
+
merged += 1;
|
|
1010
|
+
}
|
|
1011
|
+
ensureDirSymlink(agentsRoot, canonicalRoot);
|
|
1012
|
+
ensureDirSymlink(claudeRoot, canonicalRoot);
|
|
1013
|
+
if (merged)
|
|
1014
|
+
opts.log?.(`skills initialized: merged=${merged} linked=2 workdir=${workdir}`);
|
|
1015
|
+
}
|
|
1016
|
+
export function getProjectSkillPaths(workdir, skillName) {
|
|
1017
|
+
const sharedSkillFile = path.join(workdir, '.pikiclaw', 'skills', skillName, 'SKILL.md');
|
|
1018
|
+
const agentsSkillFile = path.join(workdir, '.agents', 'skills', skillName, 'SKILL.md');
|
|
1019
|
+
const claudeSkillFile = path.join(workdir, '.claude', 'skills', skillName, 'SKILL.md');
|
|
1020
|
+
const claudeCommandFile = path.join(workdir, '.claude', 'commands', `${skillName}.md`);
|
|
1021
|
+
return {
|
|
1022
|
+
sharedSkillFile: hasFile(sharedSkillFile) ? sharedSkillFile : null,
|
|
1023
|
+
agentsSkillFile: hasFile(agentsSkillFile) ? agentsSkillFile : null,
|
|
1024
|
+
claudeSkillFile: hasFile(claudeSkillFile) ? claudeSkillFile : null,
|
|
1025
|
+
claudeCommandFile: hasFile(claudeCommandFile) ? claudeCommandFile : null,
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
export function listSkills(workdir) {
|
|
1029
|
+
const skills = [];
|
|
1030
|
+
const seen = new Set();
|
|
1031
|
+
const commandsDir = path.join(workdir, '.claude', 'commands');
|
|
1032
|
+
for (const entry of readSortedDir(commandsDir)) {
|
|
1033
|
+
if (!entry.endsWith('.md'))
|
|
1034
|
+
continue;
|
|
1035
|
+
const name = entry.replace(/\.md$/, '');
|
|
1036
|
+
if (!name || seen.has(name))
|
|
1037
|
+
continue;
|
|
1038
|
+
let meta = { label: null, description: null };
|
|
1039
|
+
try {
|
|
1040
|
+
meta = parseSkillMeta(fs.readFileSync(path.join(commandsDir, entry), 'utf-8'));
|
|
1041
|
+
}
|
|
1042
|
+
catch { }
|
|
1043
|
+
skills.push({ name, label: meta.label, description: meta.description, source: 'commands' });
|
|
1044
|
+
seen.add(name);
|
|
1045
|
+
}
|
|
1046
|
+
const skillRoots = [
|
|
1047
|
+
path.join(workdir, '.pikiclaw', 'skills'),
|
|
1048
|
+
];
|
|
1049
|
+
for (const skillsDir of skillRoots) {
|
|
1050
|
+
for (const entry of readSortedDir(skillsDir)) {
|
|
1051
|
+
if (!entry || seen.has(entry))
|
|
1052
|
+
continue;
|
|
1053
|
+
const skillDir = path.join(skillsDir, entry);
|
|
1054
|
+
const skillFile = path.join(skillDir, 'SKILL.md');
|
|
1055
|
+
try {
|
|
1056
|
+
if (!fs.statSync(skillDir).isDirectory())
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
catch {
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
1062
|
+
if (!hasFile(skillFile))
|
|
1063
|
+
continue;
|
|
1064
|
+
let meta = { label: null, description: null };
|
|
1065
|
+
try {
|
|
1066
|
+
meta = parseSkillMeta(fs.readFileSync(skillFile, 'utf-8'));
|
|
1067
|
+
}
|
|
1068
|
+
catch { }
|
|
1069
|
+
skills.push({ name: entry, label: meta.label, description: meta.description, source: 'skills' });
|
|
1070
|
+
seen.add(entry);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
return { skills, workdir };
|
|
1074
|
+
}
|
|
1075
|
+
export function listModels(agent, opts = {}) {
|
|
1076
|
+
return getDriver(agent).listModels(opts);
|
|
1077
|
+
}
|
|
1078
|
+
export function getUsage(opts) {
|
|
1079
|
+
return getDriver(opts.agent).getUsage(opts);
|
|
1080
|
+
}
|