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.
Files changed (26) hide show
  1. package/dashboard/dist/assets/{AgentTab-DmKQYVz1.js → AgentTab-BFG_wBIC.js} +1 -1
  2. package/dashboard/dist/assets/{BrandIcon-KSnFdk62.js → BrandIcon-CmIkwB4h.js} +1 -1
  3. package/dashboard/dist/assets/{DirBrowser-BqA8ssEC.js → DirBrowser-BzqSOCzY.js} +1 -1
  4. package/dashboard/dist/assets/{ExtensionsTab-B86en9KI.js → ExtensionsTab-GyHiQaZK.js} +1 -1
  5. package/dashboard/dist/assets/{IMAccessTab-6L_l5Y5A.js → IMAccessTab-CO_5ztrR.js} +1 -1
  6. package/dashboard/dist/assets/{Modal-Dk3jQZ_G.js → Modal-Cx6r2XJQ.js} +1 -1
  7. package/dashboard/dist/assets/{Modals-BoS_lRW5.js → Modals-BnORfmhY.js} +1 -1
  8. package/dashboard/dist/assets/{Select-CCtTUA2O.js → Select-DLAvmM_i.js} +1 -1
  9. package/dashboard/dist/assets/{SessionPanel-DA6GsYqZ.js → SessionPanel-D2kHNUr3.js} +1 -1
  10. package/dashboard/dist/assets/{SystemTab-BQV-kjCS.js → SystemTab-BkgF1WI4.js} +1 -1
  11. package/dashboard/dist/assets/index-B_sC2ppg.js +5 -0
  12. package/dashboard/dist/assets/index-DZy1Xu_H.js +19 -0
  13. package/dashboard/dist/assets/{shared-CVa9npgA.js → shared-C-0W57w0.js} +1 -1
  14. package/dashboard/dist/index.html +1 -1
  15. package/dist/agent/drivers/claude.js +57 -0
  16. package/dist/agent/drivers/codex.js +7 -1
  17. package/dist/agent/handover.js +130 -0
  18. package/dist/agent/index.js +3 -1
  19. package/dist/agent/session.js +25 -11
  20. package/dist/bot/bot.js +146 -66
  21. package/dist/bot/commands.js +24 -4
  22. package/dist/dashboard/routes/sessions.js +8 -2
  23. package/dist/dashboard/session-control.js +104 -2
  24. package/package.json +1 -1
  25. package/dashboard/dist/assets/index-BS_RBT-T.js +0 -5
  26. 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, findThreadSessionAcrossAgents, 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, isPendingSessionId, } from '../agent/index.js';
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
- // Cross-agent context migration
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: codex CLI runs its own native `/goal`
1337
- * lifecycle (state machine + continuation engine) inside its app-server, so
1338
- * pikiclaw stays out to avoid a double loop. See setSessionGoal et al — they
1339
- * bridge to codex's `thread/goal/*` RPC instead of writing pikiclaw's
1340
- * goal.json for codex sessions.
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 context migration ──
1933
- // When starting a new session that shares a threadId with a session from a
1934
- // different agent, fetch the previous conversation tail and prepend it so the
1935
- // new agent has continuity.
1936
- if (isFirstTurnOfSession) {
1937
- const threadId = 'threadId' in cs ? cs.threadId : ('activeThreadId' in cs ? cs.activeThreadId : null);
1938
- if (threadId) {
1939
- const prevSession = findThreadSessionAcrossAgents(sessionWorkdir, threadId, cs.agent);
1940
- if (prevSession?.sessionId && prevSession.agent) {
1941
- try {
1942
- const tail = await querySessionTail({
1943
- agent: prevSession.agent,
1944
- sessionId: prevSession.sessionId,
1945
- workdir: sessionWorkdir,
1946
- limit: 20,
1947
- });
1948
- if (tail.ok && tail.messages.length) {
1949
- const contextBlock = formatCrossAgentContext(prevSession.agent, tail.messages);
1950
- if (contextBlock) {
1951
- prompt = contextBlock + '\n\n' + prompt;
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));
@@ -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
- * For codex sessions this awaits codex's native `thread/goal/*` RPC; for other
76
- * drivers it's effectively sync but stays async for a uniform call site.
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 'Cleared goal.';
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
- export function queueDashboardSessionTask(request) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiclaw",
3
- "version": "0.3.39",
3
+ "version": "0.3.41",
4
4
  "description": "Put the world's smartest AI agents in your pocket. Command local Claude & Gemini via IM. | 让最好用的 IM 变成你电脑上的顶级 Agent 控制台",
5
5
  "type": "module",
6
6
  "bin": {