pikiclaw 0.3.39 → 0.3.41
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/dashboard/dist/assets/{AgentTab-DmKQYVz1.js → AgentTab-BFG_wBIC.js} +1 -1
- package/dashboard/dist/assets/{BrandIcon-KSnFdk62.js → BrandIcon-CmIkwB4h.js} +1 -1
- package/dashboard/dist/assets/{DirBrowser-BqA8ssEC.js → DirBrowser-BzqSOCzY.js} +1 -1
- package/dashboard/dist/assets/{ExtensionsTab-B86en9KI.js → ExtensionsTab-GyHiQaZK.js} +1 -1
- package/dashboard/dist/assets/{IMAccessTab-6L_l5Y5A.js → IMAccessTab-CO_5ztrR.js} +1 -1
- package/dashboard/dist/assets/{Modal-Dk3jQZ_G.js → Modal-Cx6r2XJQ.js} +1 -1
- package/dashboard/dist/assets/{Modals-BoS_lRW5.js → Modals-BnORfmhY.js} +1 -1
- package/dashboard/dist/assets/{Select-CCtTUA2O.js → Select-DLAvmM_i.js} +1 -1
- package/dashboard/dist/assets/{SessionPanel-DA6GsYqZ.js → SessionPanel-D2kHNUr3.js} +1 -1
- package/dashboard/dist/assets/{SystemTab-BQV-kjCS.js → SystemTab-BkgF1WI4.js} +1 -1
- package/dashboard/dist/assets/index-B_sC2ppg.js +5 -0
- package/dashboard/dist/assets/index-DZy1Xu_H.js +19 -0
- package/dashboard/dist/assets/{shared-CVa9npgA.js → shared-C-0W57w0.js} +1 -1
- package/dashboard/dist/index.html +1 -1
- package/dist/agent/drivers/claude.js +57 -0
- package/dist/agent/drivers/codex.js +7 -1
- package/dist/agent/handover.js +130 -0
- package/dist/agent/index.js +3 -1
- package/dist/agent/session.js +25 -11
- package/dist/bot/bot.js +146 -66
- package/dist/bot/commands.js +24 -4
- package/dist/dashboard/routes/sessions.js +8 -2
- package/dist/dashboard/session-control.js +104 -2
- package/package.json +1 -1
- package/dashboard/dist/assets/index-BS_RBT-T.js +0 -5
- package/dashboard/dist/assets/index-C-Dh421o.js +0 -16
package/dist/bot/bot.js
CHANGED
|
@@ -7,7 +7,8 @@ import os from 'node:os';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { execSync, spawn } from 'node:child_process';
|
|
9
9
|
import { getActiveUserConfig, loadWorkspaces, onUserConfigChange, resolveUserWorkdir, setUserWorkdir, updateUserConfig } from '../core/config/user-config.js';
|
|
10
|
-
import { doStream, ensureManagedSession, findManagedThreadSession,
|
|
10
|
+
import { doStream, ensureManagedSession, findManagedThreadSession, getSessionStoredConfig, getUsage, initializeProjectSkills, listAgents, resolveAgentModels, listSkills, stageSessionFiles, reconcileOrphanedRunningSessions, getAgentBoundModelId, setAgentBoundModelId, collapseSkillPrompt, readGoal, accountTurn, shouldContinueAfterTurn, renderContinuationPrompt, renderBudgetLimitPrompt, bumpContinuationCount, pauseGoal, resumeGoal, setGoal as setGoalState, clearGoal as clearGoalState, setCodexGoal, getCodexGoal, clearCodexGoal, pauseCodexGoal, resumeCodexGoal, getClaudeNativeGoal, buildClaudeSetGoalPrompt, buildClaudeClearGoalPrompt, isPendingSessionId, } from '../agent/index.js';
|
|
11
|
+
import { compactForHandover, describeHandoverRef } from '../agent/handover.js';
|
|
11
12
|
import { querySessions, querySessionTail, updateSession, } from './session-hub.js';
|
|
12
13
|
import { getDriver, hasDriver, allDriverIds } from '../agent/driver.js';
|
|
13
14
|
import { resolveGuiIntegrationConfig } from '../agent/mcp/bridge.js';
|
|
@@ -44,37 +45,8 @@ export function thinkLabel(agent) {
|
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
47
|
// ---------------------------------------------------------------------------
|
|
47
|
-
//
|
|
48
|
+
// Prompt assembly helpers
|
|
48
49
|
// ---------------------------------------------------------------------------
|
|
49
|
-
const CROSS_AGENT_CONTEXT_MAX_CHARS = 4000;
|
|
50
|
-
const CROSS_AGENT_MSG_MAX_CHARS = 600;
|
|
51
|
-
/**
|
|
52
|
-
* Format conversation messages from a previous agent session into a compact
|
|
53
|
-
* context block that can be prepended to the first prompt of the new session.
|
|
54
|
-
*/
|
|
55
|
-
function formatCrossAgentContext(agent, messages) {
|
|
56
|
-
const lines = [];
|
|
57
|
-
let totalLen = 0;
|
|
58
|
-
for (const msg of messages) {
|
|
59
|
-
const label = msg.role === 'user' ? 'User' : 'Assistant';
|
|
60
|
-
let text = msg.text.trim();
|
|
61
|
-
if (text.length > CROSS_AGENT_MSG_MAX_CHARS) {
|
|
62
|
-
text = text.slice(0, CROSS_AGENT_MSG_MAX_CHARS) + '…';
|
|
63
|
-
}
|
|
64
|
-
const line = `${label}: ${text}`;
|
|
65
|
-
if (totalLen + line.length > CROSS_AGENT_CONTEXT_MAX_CHARS)
|
|
66
|
-
break;
|
|
67
|
-
lines.push(line);
|
|
68
|
-
totalLen += line.length;
|
|
69
|
-
}
|
|
70
|
-
if (!lines.length)
|
|
71
|
-
return '';
|
|
72
|
-
return [
|
|
73
|
-
`<previous-conversation agent="${agent}">`,
|
|
74
|
-
...lines,
|
|
75
|
-
'</previous-conversation>',
|
|
76
|
-
].join('\n');
|
|
77
|
-
}
|
|
78
50
|
function appendExtraPrompt(base, extra) {
|
|
79
51
|
const lhs = String(base || '').trim();
|
|
80
52
|
const rhs = String(extra || '').trim();
|
|
@@ -142,6 +114,18 @@ function normalizeFromCodex(goal) {
|
|
|
142
114
|
continuationCount: null,
|
|
143
115
|
};
|
|
144
116
|
}
|
|
117
|
+
function normalizeFromClaudeNative(goal) {
|
|
118
|
+
return {
|
|
119
|
+
source: 'claude',
|
|
120
|
+
objective: goal.condition,
|
|
121
|
+
// Native /goal exposes no pause/budget — it's either active or absent.
|
|
122
|
+
status: 'active',
|
|
123
|
+
tokenBudget: null,
|
|
124
|
+
tokensUsed: 0,
|
|
125
|
+
timeUsedSeconds: 0,
|
|
126
|
+
continuationCount: null,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
145
129
|
// ---------------------------------------------------------------------------
|
|
146
130
|
// Bot
|
|
147
131
|
// ---------------------------------------------------------------------------
|
|
@@ -518,6 +502,7 @@ export class Bot {
|
|
|
518
502
|
codexCumulative: session.codexCumulative,
|
|
519
503
|
modelId: session.modelId ?? null,
|
|
520
504
|
thinkingEffort: session.thinkingEffort ?? null,
|
|
505
|
+
handoverFrom: session.handoverFrom ?? null,
|
|
521
506
|
});
|
|
522
507
|
}
|
|
523
508
|
upsertSessionRuntime(session) {
|
|
@@ -544,6 +529,10 @@ export class Bot {
|
|
|
544
529
|
existing.modelId = session.modelId ?? null;
|
|
545
530
|
if (session.thinkingEffort !== undefined)
|
|
546
531
|
existing.thinkingEffort = session.thinkingEffort ?? null;
|
|
532
|
+
// handoverFrom is one-shot: only set if not already set (the first staging wins).
|
|
533
|
+
if (session.handoverFrom !== undefined && !existing.handoverFrom) {
|
|
534
|
+
existing.handoverFrom = session.handoverFrom;
|
|
535
|
+
}
|
|
547
536
|
return existing;
|
|
548
537
|
}
|
|
549
538
|
const runtime = {
|
|
@@ -557,6 +546,7 @@ export class Bot {
|
|
|
557
546
|
modelId: session.modelId ?? null,
|
|
558
547
|
thinkingEffort: session.thinkingEffort ?? null,
|
|
559
548
|
runningTaskIds: new Set(),
|
|
549
|
+
handoverFrom: session.handoverFrom ?? null,
|
|
560
550
|
};
|
|
561
551
|
this.sessionStates.set(requestedKey, runtime);
|
|
562
552
|
return runtime;
|
|
@@ -762,12 +752,19 @@ export class Bot {
|
|
|
762
752
|
const selected = this.getSelectedSession(cs);
|
|
763
753
|
if (selected)
|
|
764
754
|
return selected;
|
|
755
|
+
// Auto-resume an existing same-thread session of this agent (back-and-forth
|
|
756
|
+
// toggling). The handover queued on `cs.pendingHandoverFrom` is intentionally
|
|
757
|
+
// dropped here — the resumed session already has its own history; replaying
|
|
758
|
+
// an external handover on top would just be duplicate context.
|
|
765
759
|
const resumed = this.findThreadSessionRuntime(chatId, cs.activeThreadId, cs.agent);
|
|
766
760
|
if (resumed) {
|
|
761
|
+
cs.pendingHandoverFrom = null;
|
|
767
762
|
this.applySessionSelection(cs, resumed);
|
|
768
763
|
return resumed;
|
|
769
764
|
}
|
|
770
765
|
const wd = this.chatWorkdir(chatId);
|
|
766
|
+
const handoverFrom = cs.pendingHandoverFrom ?? null;
|
|
767
|
+
cs.pendingHandoverFrom = null;
|
|
771
768
|
const staged = stageSessionFiles({
|
|
772
769
|
agent: cs.agent,
|
|
773
770
|
workdir: wd,
|
|
@@ -775,6 +772,7 @@ export class Bot {
|
|
|
775
772
|
sessionId: null,
|
|
776
773
|
title: title || 'New session',
|
|
777
774
|
threadId: cs.activeThreadId ?? null,
|
|
775
|
+
handoverFrom,
|
|
778
776
|
});
|
|
779
777
|
const runtime = this.upsertSessionRuntime({
|
|
780
778
|
agent: cs.agent,
|
|
@@ -783,6 +781,7 @@ export class Bot {
|
|
|
783
781
|
threadId: staged.threadId,
|
|
784
782
|
modelId: this.modelForAgent(cs.agent),
|
|
785
783
|
thinkingEffort: this.effortForAgent(cs.agent),
|
|
784
|
+
handoverFrom: staged.handoverFrom,
|
|
786
785
|
});
|
|
787
786
|
this.applySessionSelection(cs, runtime);
|
|
788
787
|
return runtime;
|
|
@@ -1261,6 +1260,7 @@ export class Bot {
|
|
|
1261
1260
|
// Only override when explicitly provided — undefined skips the overwrite in upsertSessionRuntime
|
|
1262
1261
|
...(opts.modelId !== undefined ? { modelId: opts.modelId } : {}),
|
|
1263
1262
|
...(opts.thinkingEffort !== undefined ? { thinkingEffort: opts.thinkingEffort } : {}),
|
|
1263
|
+
...(opts.handoverFrom !== undefined ? { handoverFrom: opts.handoverFrom } : {}),
|
|
1264
1264
|
});
|
|
1265
1265
|
const taskId = `ext-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1266
1266
|
const prompt = opts.prompt.trim();
|
|
@@ -1333,14 +1333,14 @@ export class Bot {
|
|
|
1333
1333
|
* tasks that get cancelled or errored auto-pause the goal so the loop does
|
|
1334
1334
|
* not silently resume on the user's next message.
|
|
1335
1335
|
*
|
|
1336
|
-
* Codex sessions short-circuit:
|
|
1337
|
-
* lifecycle (state machine
|
|
1338
|
-
* pikiclaw stays out to avoid a double loop. See setSessionGoal
|
|
1339
|
-
* bridge to codex's `thread/goal/*` RPC
|
|
1340
|
-
* goal
|
|
1336
|
+
* Codex and Claude sessions short-circuit: each runs its own native `/goal`
|
|
1337
|
+
* lifecycle (codex's app-server state machine; claude's in-process Stop
|
|
1338
|
+
* hook), so pikiclaw stays out to avoid a double loop. See setSessionGoal
|
|
1339
|
+
* et al — they bridge to codex's `thread/goal/*` RPC and to claude's
|
|
1340
|
+
* `/goal <condition>` slash command instead of writing pikiclaw's goal.json.
|
|
1341
1341
|
*/
|
|
1342
1342
|
maybeEnqueueGoalContinuation(session, opts, result) {
|
|
1343
|
-
if (session.agent === 'codex')
|
|
1343
|
+
if (session.agent === 'codex' || session.agent === 'claude')
|
|
1344
1344
|
return;
|
|
1345
1345
|
const sessionId = (result.sessionId || session.sessionId || '').trim();
|
|
1346
1346
|
if (!sessionId || isPendingSessionId(sessionId))
|
|
@@ -1420,6 +1420,12 @@ export class Bot {
|
|
|
1420
1420
|
const goal = await getCodexGoal(sessionId);
|
|
1421
1421
|
return goal ? normalizeFromCodex(goal) : null;
|
|
1422
1422
|
}
|
|
1423
|
+
if (agent === 'claude') {
|
|
1424
|
+
if (!sessionId || isPendingSessionId(sessionId))
|
|
1425
|
+
return null;
|
|
1426
|
+
const goal = getClaudeNativeGoal(workdir, sessionId);
|
|
1427
|
+
return goal ? normalizeFromClaudeNative(goal) : null;
|
|
1428
|
+
}
|
|
1423
1429
|
const goal = readGoal(workdir, agent, sessionId);
|
|
1424
1430
|
return goal ? normalizeFromPikiclaw(goal) : null;
|
|
1425
1431
|
}
|
|
@@ -1448,6 +1454,37 @@ export class Bot {
|
|
|
1448
1454
|
throw new Error('codex did not return a goal snapshot');
|
|
1449
1455
|
return normalizeFromCodex(goal);
|
|
1450
1456
|
}
|
|
1457
|
+
if (agent === 'claude') {
|
|
1458
|
+
if (!sessionId || isPendingSessionId(sessionId)) {
|
|
1459
|
+
throw new Error('claude session must exist before /goal — send a first message to create the transcript');
|
|
1460
|
+
}
|
|
1461
|
+
// Native /goal owns its own continuation engine (Stop hook). pikiclaw
|
|
1462
|
+
// just submits the slash command as the next task; claude internally
|
|
1463
|
+
// sets up the goal_status attachment, injects its meta directive, and
|
|
1464
|
+
// keeps looping until the Haiku completion check returns met. Token
|
|
1465
|
+
// budget is accepted in the API for shape parity with codex/portable
|
|
1466
|
+
// but ignored — claude native /goal has no budget concept.
|
|
1467
|
+
const objective = opts.objective.trim();
|
|
1468
|
+
if (!objective)
|
|
1469
|
+
throw new Error('objective must be non-empty');
|
|
1470
|
+
this.submitSessionTask({
|
|
1471
|
+
agent,
|
|
1472
|
+
sessionId,
|
|
1473
|
+
workdir,
|
|
1474
|
+
prompt: buildClaudeSetGoalPrompt(objective),
|
|
1475
|
+
chatId: opts.chatId,
|
|
1476
|
+
modelId: opts.modelId,
|
|
1477
|
+
thinkingEffort: opts.thinkingEffort,
|
|
1478
|
+
});
|
|
1479
|
+
// Return an optimistic snapshot — the actual goal_status attachment is
|
|
1480
|
+
// written by claude during the task; readers can poll getSessionGoal.
|
|
1481
|
+
return normalizeFromClaudeNative({
|
|
1482
|
+
condition: objective,
|
|
1483
|
+
status: 'active',
|
|
1484
|
+
met: false,
|
|
1485
|
+
updatedAtMs: Date.now(),
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1451
1488
|
const goal = setGoalState(workdir, agent, sessionId, {
|
|
1452
1489
|
objective: opts.objective,
|
|
1453
1490
|
tokenBudget: opts.tokenBudget ?? null,
|
|
@@ -1477,6 +1514,11 @@ export class Bot {
|
|
|
1477
1514
|
const goal = resp.goal ?? (await getCodexGoal(sessionId));
|
|
1478
1515
|
return goal ? normalizeFromCodex(goal) : null;
|
|
1479
1516
|
}
|
|
1517
|
+
if (agent === 'claude') {
|
|
1518
|
+
// Claude's native /goal exposes no pause/resume — only set and clear.
|
|
1519
|
+
// Surface a clear error so the IM layer can render a friendly message.
|
|
1520
|
+
throw new Error('Claude native /goal does not support pause/resume — only `/goal clear`. Re-issue `/goal <objective>` to start fresh.');
|
|
1521
|
+
}
|
|
1480
1522
|
const goal = pauseGoal(workdir, agent, sessionId);
|
|
1481
1523
|
return goal ? normalizeFromPikiclaw(goal) : null;
|
|
1482
1524
|
}
|
|
@@ -1490,6 +1532,9 @@ export class Bot {
|
|
|
1490
1532
|
const goal = resp.goal ?? (await getCodexGoal(sessionId));
|
|
1491
1533
|
return goal ? normalizeFromCodex(goal) : null;
|
|
1492
1534
|
}
|
|
1535
|
+
if (agent === 'claude') {
|
|
1536
|
+
throw new Error('Claude native /goal does not support pause/resume — re-issue `/goal <objective>` to start fresh.');
|
|
1537
|
+
}
|
|
1493
1538
|
const goal = resumeGoal(workdir, agent, sessionId);
|
|
1494
1539
|
if (!goal || goal.status !== 'active')
|
|
1495
1540
|
return goal ? normalizeFromPikiclaw(goal) : null;
|
|
@@ -1508,7 +1553,7 @@ export class Bot {
|
|
|
1508
1553
|
}
|
|
1509
1554
|
return normalizeFromPikiclaw(goal);
|
|
1510
1555
|
}
|
|
1511
|
-
async clearSessionGoal(workdir, agent, sessionId) {
|
|
1556
|
+
async clearSessionGoal(workdir, agent, sessionId, opts = {}) {
|
|
1512
1557
|
if (agent === 'codex') {
|
|
1513
1558
|
if (!sessionId || isPendingSessionId(sessionId))
|
|
1514
1559
|
return;
|
|
@@ -1517,6 +1562,24 @@ export class Bot {
|
|
|
1517
1562
|
throw new Error(resp.error);
|
|
1518
1563
|
return;
|
|
1519
1564
|
}
|
|
1565
|
+
if (agent === 'claude') {
|
|
1566
|
+
if (!sessionId || isPendingSessionId(sessionId))
|
|
1567
|
+
return;
|
|
1568
|
+
// Read goal-status first to avoid spawning a no-op turn when nothing is set.
|
|
1569
|
+
const existing = getClaudeNativeGoal(workdir, sessionId);
|
|
1570
|
+
if (!existing)
|
|
1571
|
+
return;
|
|
1572
|
+
this.submitSessionTask({
|
|
1573
|
+
agent,
|
|
1574
|
+
sessionId,
|
|
1575
|
+
workdir,
|
|
1576
|
+
prompt: buildClaudeClearGoalPrompt(),
|
|
1577
|
+
chatId: opts.chatId,
|
|
1578
|
+
modelId: opts.modelId,
|
|
1579
|
+
thinkingEffort: opts.thinkingEffort,
|
|
1580
|
+
});
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1520
1583
|
clearGoalState(workdir, agent, sessionId);
|
|
1521
1584
|
}
|
|
1522
1585
|
cancelTask(taskId) {
|
|
@@ -1577,15 +1640,32 @@ export class Bot {
|
|
|
1577
1640
|
const cs = this.chat(chatId);
|
|
1578
1641
|
if (cs.agent === agent)
|
|
1579
1642
|
return false;
|
|
1643
|
+
// Capture the live session of the *outgoing* agent so the next message to
|
|
1644
|
+
// the new agent can replay it as a handover. We capture BEFORE flipping
|
|
1645
|
+
// cs.agent so the ref is honest about which agent it points at.
|
|
1646
|
+
const prevAgent = cs.agent;
|
|
1647
|
+
const prevSessionId = cs.sessionId && !isPendingSessionId(cs.sessionId) ? cs.sessionId : null;
|
|
1580
1648
|
cs.agent = agent;
|
|
1649
|
+
// Pre-existing session of the new agent in this thread — back-and-forth
|
|
1650
|
+
// toggling resumes it without handover. The user's intent is "continue what
|
|
1651
|
+
// I had", not "translate cross-agent".
|
|
1581
1652
|
const resumed = this.findThreadSessionRuntime(chatId, cs.activeThreadId, agent);
|
|
1582
1653
|
if (resumed) {
|
|
1654
|
+
cs.pendingHandoverFrom = null;
|
|
1583
1655
|
this.applySessionSelection(cs, resumed);
|
|
1584
1656
|
this.log(`agent switched to ${agent} chat=${chatId} resumed=${resumed.sessionId}`);
|
|
1585
1657
|
return true;
|
|
1586
1658
|
}
|
|
1659
|
+
// No existing session of the new agent → next message will stage a fresh
|
|
1660
|
+
// one. Park the outgoing session as the handover source. If the outgoing
|
|
1661
|
+
// agent had no live session (e.g. the user is rapidly toggling agents
|
|
1662
|
+
// before sending anything), keep any already-pending handover so the
|
|
1663
|
+
// original source isn't lost across intermediate switches.
|
|
1664
|
+
if (prevSessionId) {
|
|
1665
|
+
cs.pendingHandoverFrom = { agent: prevAgent, sessionId: prevSessionId };
|
|
1666
|
+
}
|
|
1587
1667
|
this.resetChatConversation(cs, { clearThread: false });
|
|
1588
|
-
this.log(`agent switched to ${agent} chat=${chatId}`);
|
|
1668
|
+
this.log(`agent switched to ${agent} chat=${chatId} handoverFrom=${describeHandoverRef(cs.pendingHandoverFrom)}`);
|
|
1589
1669
|
return true;
|
|
1590
1670
|
}
|
|
1591
1671
|
switchModelForChat(chatId, modelId) {
|
|
@@ -1929,34 +2009,34 @@ export class Bot {
|
|
|
1929
2009
|
this.debug(`[runStream] agent=${cs.agent} session=${cs.sessionId || '(new)'} workdir=${sessionWorkdir} timeout=${this.runTimeout}s attachments=${attachments.length}`);
|
|
1930
2010
|
this.debug(`[runStream] ${cs.agent} config: model=${resolvedModel} extraArgs=[${extraArgs.join(' ')}]`);
|
|
1931
2011
|
const isFirstTurnOfSession = !cs.sessionId || isPendingSessionId(cs.sessionId);
|
|
1932
|
-
// ── Cross-agent
|
|
1933
|
-
//
|
|
1934
|
-
//
|
|
1935
|
-
// new agent
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
this.debug(`[runStream] injected cross-agent context from ${prevSession.agent}:${prevSession.sessionId} (${tail.messages.length} msgs)`);
|
|
1953
|
-
}
|
|
1954
|
-
}
|
|
1955
|
-
}
|
|
1956
|
-
catch (e) {
|
|
1957
|
-
this.debug(`[runStream] cross-agent context fetch failed: ${e?.message || e}`);
|
|
1958
|
-
}
|
|
2012
|
+
// ── Cross-agent handover ──
|
|
2013
|
+
// First turn of a session created by an agent switch: read the prior agent's
|
|
2014
|
+
// session, compact it, and prepend the seed to this turn's prompt. After this
|
|
2015
|
+
// single injection the new agent owns the canonical session file and `--resume`
|
|
2016
|
+
// takes over. See agent/handover.ts.
|
|
2017
|
+
const handoverFrom = ('handoverFrom' in cs && cs.handoverFrom) ? cs.handoverFrom : null;
|
|
2018
|
+
if (isFirstTurnOfSession && handoverFrom) {
|
|
2019
|
+
try {
|
|
2020
|
+
const result = await compactForHandover({
|
|
2021
|
+
fromAgent: handoverFrom.agent,
|
|
2022
|
+
fromSessionId: handoverFrom.sessionId,
|
|
2023
|
+
workdir: sessionWorkdir,
|
|
2024
|
+
toAgent: cs.agent,
|
|
2025
|
+
toModel: resolvedModel,
|
|
2026
|
+
});
|
|
2027
|
+
if (result.ok && result.seed) {
|
|
2028
|
+
prompt = result.seed + '\n\n' + prompt;
|
|
2029
|
+
this.debug(`[runStream] handover ${describeHandoverRef(handoverFrom)} → ${cs.agent} `
|
|
2030
|
+
+ `mode=${result.mode} msgs=${result.messagesIncluded}/${result.messagesTotal} `
|
|
2031
|
+
+ `turnsTotal=${result.turnsTotal} chars=${result.charsIncluded}/${result.budgetChars}`);
|
|
1959
2032
|
}
|
|
2033
|
+
else {
|
|
2034
|
+
this.warn(`[runStream] handover ${describeHandoverRef(handoverFrom)} → ${cs.agent} `
|
|
2035
|
+
+ `failed (${result.error || 'unknown'}); proceeding without prior context`);
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
catch (e) {
|
|
2039
|
+
this.warn(`[runStream] handover threw: ${e?.message || e}; proceeding without prior context`);
|
|
1960
2040
|
}
|
|
1961
2041
|
}
|
|
1962
2042
|
const mcpSystemPrompt = appendExtraPrompt(appendExtraPrompt(mcpSendFile ? buildMcpDeliveryPrompt() : '', onInteraction && cs.agent === 'claude' ? buildClaudeAskUserPrompt() : ''), buildBrowserAutomationPrompt(browserEnabled));
|
package/dist/bot/commands.js
CHANGED
|
@@ -72,8 +72,11 @@ export function getWorkspacesData(bot, chatId) {
|
|
|
72
72
|
* the IM renderer to send back. Returns null when there is no active session
|
|
73
73
|
* for the chat (caller renders its own "pick a session first" message).
|
|
74
74
|
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
75
|
+
* Per-agent routing:
|
|
76
|
+
* - codex → native `thread/goal/*` RPC (state machine + budget + pause/resume)
|
|
77
|
+
* - claude → native `/goal <condition>` slash command (Stop hook continuation,
|
|
78
|
+
* auto-clear on completion, no budget / no pause/resume)
|
|
79
|
+
* - others → pikiclaw's portable goal.json with continuation injection
|
|
77
80
|
*/
|
|
78
81
|
export async function handleGoalCommand(bot, chatId, rawArgs) {
|
|
79
82
|
const session = bot.selectedSession(chatId);
|
|
@@ -104,12 +107,17 @@ export async function handleGoalCommand(bot, chatId, rawArgs) {
|
|
|
104
107
|
return `Resumed goal: ${truncate(goal.objective, 80)}`;
|
|
105
108
|
}
|
|
106
109
|
if (lower === 'clear' || lower === 'cancel' || lower === 'stop') {
|
|
107
|
-
await bot.clearSessionGoal(workdir, agent, sessionId);
|
|
108
|
-
return
|
|
110
|
+
await bot.clearSessionGoal(workdir, agent, sessionId, { chatId });
|
|
111
|
+
return agent === 'claude'
|
|
112
|
+
? 'Submitted `/goal clear` to claude. (Native /goal auto-clears once the condition is met, so this is only needed to stop early.)'
|
|
113
|
+
: 'Cleared goal.';
|
|
109
114
|
}
|
|
110
115
|
const { objective, tokenBudget } = parseObjective(args);
|
|
111
116
|
if (!objective)
|
|
112
117
|
return 'Usage: /goal <objective> (or pause / resume / clear)';
|
|
118
|
+
if (agent === 'claude' && tokenBudget != null) {
|
|
119
|
+
return 'Claude native /goal does not support `budget=N` — drop the budget prefix. (Use a codex session if you need a token budget.)';
|
|
120
|
+
}
|
|
113
121
|
const goal = await bot.setSessionGoal(workdir, agent, sessionId, {
|
|
114
122
|
objective,
|
|
115
123
|
tokenBudget,
|
|
@@ -122,6 +130,12 @@ export async function handleGoalCommand(bot, chatId, rawArgs) {
|
|
|
122
130
|
'Send any message to trigger codex\'s native continuation loop. Each message resumes the thread and codex audits / continues until it marks the goal complete or hits the budget.',
|
|
123
131
|
].join('\n');
|
|
124
132
|
}
|
|
133
|
+
if (agent === 'claude') {
|
|
134
|
+
return [
|
|
135
|
+
`Goal set (claude native): ${truncate(goal.objective, 120)}`,
|
|
136
|
+
'Claude\'s in-process Stop hook keeps working until a Haiku judge confirms the condition is met, then auto-clears. Send `/goal clear` to stop early; `/goal` to inspect.',
|
|
137
|
+
].join('\n');
|
|
138
|
+
}
|
|
125
139
|
return `Goal set${budgetLabel}: ${truncate(goal.objective, 120)}\nThe agent will keep working until it audits the objective complete${goal.tokenBudget != null ? ' or exhausts the budget' : ''}.`;
|
|
126
140
|
}
|
|
127
141
|
catch (e) {
|
|
@@ -131,6 +145,12 @@ export async function handleGoalCommand(bot, chatId, rawArgs) {
|
|
|
131
145
|
function formatGoalStatusLine(goal, agent) {
|
|
132
146
|
if (!goal)
|
|
133
147
|
return 'No goal set for this session. Use `/goal <objective>` to set one.';
|
|
148
|
+
if (goal.source === 'claude') {
|
|
149
|
+
return [
|
|
150
|
+
`Goal: ${truncate(goal.objective, 200)}`,
|
|
151
|
+
`Status: ${goal.status} · claude native (Stop hook, auto-clears on completion)`,
|
|
152
|
+
].join('\n');
|
|
153
|
+
}
|
|
134
154
|
const budget = goal.tokenBudget != null
|
|
135
155
|
? `${goal.tokensUsed}/${goal.tokenBudget} tokens`
|
|
136
156
|
: `${goal.tokensUsed} tokens (no budget)`;
|
|
@@ -122,6 +122,8 @@ async function parseSessionSendRequest(c) {
|
|
|
122
122
|
model: readStringField(form.get('model')),
|
|
123
123
|
effort: readStringField(form.get('effort')).toLowerCase(),
|
|
124
124
|
attachments: uploads.attachments,
|
|
125
|
+
previousAgent: readStringField(form.get('previousAgent')),
|
|
126
|
+
previousSessionId: readStringField(form.get('previousSessionId')),
|
|
125
127
|
cleanup: uploads.cleanup,
|
|
126
128
|
};
|
|
127
129
|
}
|
|
@@ -134,6 +136,8 @@ async function parseSessionSendRequest(c) {
|
|
|
134
136
|
model: readStringField(body?.model),
|
|
135
137
|
effort: readStringField(body?.effort).toLowerCase(),
|
|
136
138
|
attachments: [],
|
|
139
|
+
previousAgent: readStringField(body?.previousAgent),
|
|
140
|
+
previousSessionId: readStringField(body?.previousSessionId),
|
|
137
141
|
cleanup: async () => { },
|
|
138
142
|
};
|
|
139
143
|
}
|
|
@@ -436,8 +440,8 @@ app.get('/api/session-hub/skills', (c) => {
|
|
|
436
440
|
// ==========================================================================
|
|
437
441
|
app.post('/api/session-hub/session/send', async (c) => {
|
|
438
442
|
try {
|
|
439
|
-
const { workdir, agent, sessionId, prompt, model, effort, attachments, cleanup } = await parseSessionSendRequest(c);
|
|
440
|
-
const queued = queueDashboardSessionTask({
|
|
443
|
+
const { workdir, agent, sessionId, prompt, model, effort, attachments, previousAgent, previousSessionId, cleanup } = await parseSessionSendRequest(c);
|
|
444
|
+
const queued = await queueDashboardSessionTask({
|
|
441
445
|
workdir,
|
|
442
446
|
agent,
|
|
443
447
|
sessionId,
|
|
@@ -445,6 +449,8 @@ app.post('/api/session-hub/session/send', async (c) => {
|
|
|
445
449
|
model,
|
|
446
450
|
effort,
|
|
447
451
|
attachments,
|
|
452
|
+
previousAgent: previousAgent || null,
|
|
453
|
+
previousSessionId: previousSessionId || null,
|
|
448
454
|
});
|
|
449
455
|
await cleanup();
|
|
450
456
|
if (!queued.ok) {
|
|
@@ -2,10 +2,38 @@
|
|
|
2
2
|
* Public session task control surface for dashboard and API routes.
|
|
3
3
|
*/
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { getProjectSkillPaths, listSkills, stageSessionFiles, ensureManagedSession, getDriverCapabilities } from '../agent/index.js';
|
|
5
|
+
import { getProjectSkillPaths, listSkills, stageSessionFiles, ensureManagedSession, findPikiclawSession, getDriverCapabilities, isPendingSessionId } from '../agent/index.js';
|
|
6
6
|
import { loadUserConfig } from '../core/config/user-config.js';
|
|
7
7
|
import { runtime } from './runtime.js';
|
|
8
8
|
const KNOWN_AGENTS = new Set(['claude', 'codex', 'gemini', 'hermes']);
|
|
9
|
+
/**
|
|
10
|
+
* Parse a `/goal[ args]` prompt typed in the dashboard chat box. Returns null
|
|
11
|
+
* when the prompt is not a goal slash command. Sub-commands mirror the IM
|
|
12
|
+
* `handleGoalCommand` semantics (set / clear / pause / resume / status).
|
|
13
|
+
*
|
|
14
|
+
* Routing /goal through the native bridge is the dashboard's analog of what
|
|
15
|
+
* channels/{telegram,feishu,weixin}/bot.ts do via `handleGoalCommand` — before
|
|
16
|
+
* this hook, dashboard /goal was matched by the legacy `goal` skill resolver
|
|
17
|
+
* and silently rewritten to "Read SKILL.md and execute", which bypassed both
|
|
18
|
+
* the claude native /goal slash command and codex's thread/goal RPC.
|
|
19
|
+
*/
|
|
20
|
+
function parseGoalSlash(prompt) {
|
|
21
|
+
const trimmed = prompt.trim();
|
|
22
|
+
const m = trimmed.match(/^\/goal(?:\s+([\s\S]*))?$/);
|
|
23
|
+
if (!m)
|
|
24
|
+
return null;
|
|
25
|
+
const args = (m[1] || '').trim();
|
|
26
|
+
if (!args)
|
|
27
|
+
return { action: 'status', objective: '' };
|
|
28
|
+
const lower = args.toLowerCase();
|
|
29
|
+
if (lower === 'clear' || lower === 'cancel' || lower === 'stop')
|
|
30
|
+
return { action: 'clear', objective: '' };
|
|
31
|
+
if (lower === 'pause')
|
|
32
|
+
return { action: 'pause', objective: '' };
|
|
33
|
+
if (lower === 'resume')
|
|
34
|
+
return { action: 'resume', objective: '' };
|
|
35
|
+
return { action: 'set', objective: args };
|
|
36
|
+
}
|
|
9
37
|
/**
|
|
10
38
|
* Resolve a `/skill-name [args]` prompt into the full skill execution prompt.
|
|
11
39
|
* Returns null if the prompt is not a skill invocation or the skill is not found.
|
|
@@ -33,7 +61,29 @@ function resolveSkillFromPrompt(workdir, prompt) {
|
|
|
33
61
|
const resolvedPrompt = `${workdirHint}Read the skill definition at \`${targetPath}\` and execute the instructions defined there.${extra}`;
|
|
34
62
|
return { resolvedPrompt, skillName: skill.name };
|
|
35
63
|
}
|
|
36
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Resolve a `handoverFrom` ref from the request's `previousAgent` /
|
|
66
|
+
* `previousSessionId` fields, validating that it points to a real, non-self,
|
|
67
|
+
* different-agent session managed by pikiclaw. Returns null when the inputs
|
|
68
|
+
* are absent or invalid — handover is best-effort and silent-skip on bad data.
|
|
69
|
+
*/
|
|
70
|
+
function resolveHandoverFrom(request, targetAgent) {
|
|
71
|
+
const prevAgent = typeof request.previousAgent === 'string' ? request.previousAgent.trim() : '';
|
|
72
|
+
const prevSessionId = typeof request.previousSessionId === 'string' ? request.previousSessionId.trim() : '';
|
|
73
|
+
if (!prevAgent || !prevSessionId)
|
|
74
|
+
return null;
|
|
75
|
+
if (!KNOWN_AGENTS.has(prevAgent))
|
|
76
|
+
return null;
|
|
77
|
+
if (prevAgent === targetAgent)
|
|
78
|
+
return null; // same-agent continuation goes via --resume, not handover
|
|
79
|
+
if (isPendingSessionId(prevSessionId))
|
|
80
|
+
return null; // no native history yet → nothing to compact
|
|
81
|
+
const record = findPikiclawSession(request.workdir, prevAgent, prevSessionId);
|
|
82
|
+
if (!record)
|
|
83
|
+
return null;
|
|
84
|
+
return { agent: prevAgent, sessionId: prevSessionId };
|
|
85
|
+
}
|
|
86
|
+
export async function queueDashboardSessionTask(request) {
|
|
37
87
|
const bot = runtime.getBotRef();
|
|
38
88
|
if (!bot)
|
|
39
89
|
return { ok: false, error: 'Bot is not running' };
|
|
@@ -48,6 +98,14 @@ export function queueDashboardSessionTask(request) {
|
|
|
48
98
|
const thinkingEffort = resolvedAgent === 'gemini'
|
|
49
99
|
? ''
|
|
50
100
|
: (typeof request.effort === 'string' ? request.effort.trim().toLowerCase() : '');
|
|
101
|
+
// /goal — route directly to the goal bridge (claude native slash, codex RPC,
|
|
102
|
+
// or portable goal.json for gemini/hermes). Must run BEFORE skill resolution
|
|
103
|
+
// so the legacy `goal` skill doesn't grab the prompt and rewrite it into a
|
|
104
|
+
// "Read SKILL.md" instruction.
|
|
105
|
+
const goalCmd = parseGoalSlash(request.prompt || '');
|
|
106
|
+
if (goalCmd && request.sessionId && !isPendingSessionId(request.sessionId)) {
|
|
107
|
+
return runDashboardGoalSlash(bot, resolvedAgent, request, goalCmd, modelId, thinkingEffort);
|
|
108
|
+
}
|
|
51
109
|
// Resolve /skill-name prompts into full skill execution prompts
|
|
52
110
|
let prompt = request.prompt;
|
|
53
111
|
const skillResult = prompt ? resolveSkillFromPrompt(request.workdir, prompt) : null;
|
|
@@ -57,6 +115,11 @@ export function queueDashboardSessionTask(request) {
|
|
|
57
115
|
}
|
|
58
116
|
let sessionId = request.sessionId;
|
|
59
117
|
let attachments = request.attachments || [];
|
|
118
|
+
// Resolve handover source. Only meaningful when we're about to stage a fresh
|
|
119
|
+
// session (sessionId blank or pending). For an existing session we never
|
|
120
|
+
// replay handover — that session's own --resume history is canonical.
|
|
121
|
+
const isFreshSession = !sessionId || isPendingSessionId(sessionId);
|
|
122
|
+
const handoverFrom = isFreshSession ? resolveHandoverFrom(request, resolvedAgent) : null;
|
|
60
123
|
// Stage files into the session workspace so temp uploads survive cleanup.
|
|
61
124
|
// Also creates a new pending session when no sessionId is provided.
|
|
62
125
|
if (!sessionId || attachments.length) {
|
|
@@ -67,6 +130,7 @@ export function queueDashboardSessionTask(request) {
|
|
|
67
130
|
sessionId: sessionId || null,
|
|
68
131
|
title: request.prompt || 'New session',
|
|
69
132
|
threadId: null,
|
|
133
|
+
handoverFrom,
|
|
70
134
|
});
|
|
71
135
|
if (!sessionId)
|
|
72
136
|
sessionId = staged.sessionId;
|
|
@@ -82,8 +146,46 @@ export function queueDashboardSessionTask(request) {
|
|
|
82
146
|
attachments,
|
|
83
147
|
...(modelId ? { modelId } : {}),
|
|
84
148
|
...(thinkingEffort ? { thinkingEffort } : {}),
|
|
149
|
+
...(handoverFrom ? { handoverFrom } : {}),
|
|
85
150
|
});
|
|
86
151
|
}
|
|
152
|
+
async function runDashboardGoalSlash(bot, agent, request, cmd, modelId, thinkingEffort) {
|
|
153
|
+
const opts = { chatId: 'dashboard', modelId: modelId || undefined, thinkingEffort: thinkingEffort || undefined };
|
|
154
|
+
const sessionKey = `${agent}:${request.sessionId}`;
|
|
155
|
+
// Synthetic task id — for set / clear / resume on agents that internally
|
|
156
|
+
// submit a follow-up task (claude native slash, portable continuation),
|
|
157
|
+
// the real task id is owned by submitSessionTask. The dashboard's SSE
|
|
158
|
+
// stream listener picks that up via session events; this id is just to
|
|
159
|
+
// give the HTTP caller a non-empty taskId field.
|
|
160
|
+
const taskId = `goal-${cmd.action}-${Date.now().toString(36)}`;
|
|
161
|
+
try {
|
|
162
|
+
if (cmd.action === 'status') {
|
|
163
|
+
const goal = await bot.getSessionGoal(request.workdir, agent, request.sessionId);
|
|
164
|
+
return { ok: true, taskId, sessionKey, queued: false, goal };
|
|
165
|
+
}
|
|
166
|
+
if (cmd.action === 'clear') {
|
|
167
|
+
await bot.clearSessionGoal(request.workdir, agent, request.sessionId, opts);
|
|
168
|
+
return { ok: true, taskId, sessionKey, queued: false };
|
|
169
|
+
}
|
|
170
|
+
if (cmd.action === 'pause') {
|
|
171
|
+
const goal = await bot.pauseSessionGoal(request.workdir, agent, request.sessionId);
|
|
172
|
+
return { ok: true, taskId, sessionKey, queued: false, goal };
|
|
173
|
+
}
|
|
174
|
+
if (cmd.action === 'resume') {
|
|
175
|
+
const goal = await bot.resumeSessionGoal(request.workdir, agent, request.sessionId, opts);
|
|
176
|
+
return { ok: true, taskId, sessionKey, queued: false, goal };
|
|
177
|
+
}
|
|
178
|
+
// set
|
|
179
|
+
const goal = await bot.setSessionGoal(request.workdir, agent, request.sessionId, {
|
|
180
|
+
objective: cmd.objective,
|
|
181
|
+
...opts,
|
|
182
|
+
});
|
|
183
|
+
return { ok: true, taskId, sessionKey, queued: true, goal };
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
return { ok: false, error: e?.message || String(e) };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
87
189
|
export function forkDashboardSessionTask(request) {
|
|
88
190
|
const bot = runtime.getBotRef();
|
|
89
191
|
if (!bot)
|
package/package.json
CHANGED