pikiloom 0.4.70 → 0.4.72
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/dist/agent/drivers/codex.js +6 -4
- package/dist/cli/kernel-app.js +5 -1
- package/dist/core/config/runtime-config.js +33 -4
- package/dist/dashboard/routes/agents.js +4 -2
- package/dist/dashboard/session-control.js +3 -3
- package/package.json +1 -1
- package/packages/kernel/dist/contracts/driver.d.ts +8 -0
- package/packages/kernel/dist/contracts/ports.d.ts +4 -0
- package/packages/kernel/dist/contracts/surface.d.ts +7 -0
- package/packages/kernel/dist/drivers/claude.d.ts +3 -0
- package/packages/kernel/dist/drivers/claude.js +18 -4
- package/packages/kernel/dist/drivers/codex.js +18 -0
- package/packages/kernel/dist/ports/defaults.d.ts +1 -0
- package/packages/kernel/dist/ports/defaults.js +20 -0
- package/packages/kernel/dist/protocol/index.d.ts +8 -0
- package/packages/kernel/dist/runtime/hub.d.ts +7 -0
- package/packages/kernel/dist/runtime/hub.js +71 -0
- package/packages/kernel/dist/runtime/session-runner.js +3 -0
|
@@ -276,9 +276,11 @@ export async function pauseCodexGoal(threadId) {
|
|
|
276
276
|
export async function resumeCodexGoal(threadId) {
|
|
277
277
|
return setCodexGoal({ threadId, status: 'active' });
|
|
278
278
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
279
|
+
// Codex (GPT-5.6+) accepts low / medium / high / xhigh / max / ultra as native reasoning levels,
|
|
280
|
+
// so the effort token is forwarded to the app-server verbatim. Only `min` is an alias for the
|
|
281
|
+
// CLI's `minimal`. (Historically `max` was folded to `xhigh` because older Codex had no `max`
|
|
282
|
+
// rung — that clamp is gone now that 5.6 exposes `max`/`ultra` directly.)
|
|
283
|
+
const EFFORT_MAP = { min: 'minimal' };
|
|
282
284
|
function mapEffort(effort) { return EFFORT_MAP[effort] ?? effort; }
|
|
283
285
|
function isCodexToolCallItem(item) {
|
|
284
286
|
return item?.type === 'dynamicToolCall' || item?.type === 'mcpToolCall' || item?.type === 'collabAgentToolCall';
|
|
@@ -808,7 +810,7 @@ export function humanizeCodexError(raw) {
|
|
|
808
810
|
const model = match[1];
|
|
809
811
|
return `Codex 正在使用 ChatGPT 账号登录,无法运行第三方模型「${model}」。`
|
|
810
812
|
+ `请在「智能体配置」页接入该模型的供应商(填入 Base URL 与 API Key),添加对应模型档案并绑定到 Codex,`
|
|
811
|
-
+ `或改用 Codex 原生模型(如 gpt-5.
|
|
813
|
+
+ `或改用 Codex 原生模型(如 gpt-5.6-sol)。`;
|
|
812
814
|
}
|
|
813
815
|
return raw;
|
|
814
816
|
}
|
package/dist/cli/kernel-app.js
CHANGED
|
@@ -17,7 +17,11 @@ import { resolveAgentInjection } from '../model/injector.js';
|
|
|
17
17
|
// queue, wire) is live here.
|
|
18
18
|
const DEMO_MODELS = {
|
|
19
19
|
claude: [{ id: 'claude-opus-4-8', label: 'Opus 4.8', providerName: 'anthropic', contextWindow: 200000 }],
|
|
20
|
-
codex: [
|
|
20
|
+
codex: [
|
|
21
|
+
{ id: 'gpt-5.6-sol', label: 'GPT-5.6-Sol', providerName: 'openai', contextWindow: 372000 },
|
|
22
|
+
{ id: 'gpt-5.6-terra', label: 'GPT-5.6-Terra', providerName: 'openai', contextWindow: 372000 },
|
|
23
|
+
{ id: 'gpt-5.6-luna', label: 'GPT-5.6-Luna', providerName: 'openai', contextWindow: 372000 },
|
|
24
|
+
],
|
|
21
25
|
gemini: [{ id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', providerName: 'google', contextWindow: 1000000 }],
|
|
22
26
|
opencode: [{ id: 'claude-sonnet-4-6', label: 'OpenCode · Sonnet 4.6 (ACP)', providerName: 'opencode', contextWindow: 200000 }],
|
|
23
27
|
echo: [{ id: 'echo-1', label: 'Echo (hermetic)', providerName: 'local', contextWindow: 8192 }],
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { normalizeClaudeModelId } from '../../agent/index.js';
|
|
2
|
+
/** Account-scoped GPT-5.6 variants the Codex model catalog currently advertises. */
|
|
3
|
+
export const CODEX_56_MODEL_IDS = [
|
|
4
|
+
'gpt-5.6-sol',
|
|
5
|
+
'gpt-5.6-terra',
|
|
6
|
+
'gpt-5.6-luna',
|
|
7
|
+
];
|
|
2
8
|
export const DEFAULT_AGENT_MODELS = {
|
|
3
9
|
claude: 'claude-opus-4-8',
|
|
4
|
-
codex:
|
|
10
|
+
codex: CODEX_56_MODEL_IDS[0],
|
|
5
11
|
gemini: 'gemini-3.1-pro-preview',
|
|
6
12
|
hermes: 'anthropic/claude-sonnet-4',
|
|
7
13
|
};
|
|
@@ -178,16 +184,39 @@ export function effortLabel(id) {
|
|
|
178
184
|
// so per-model/provider rules stay in one place. Labels come from effortLabel() so the picker
|
|
179
185
|
// and the live status row can never diverge.
|
|
180
186
|
const effortLevels = (...ids) => ids.map(id => ({ id, label: effortLabel(id) }));
|
|
187
|
+
const CODEX_BASE_EFFORTS = ['low', 'medium', 'high', 'xhigh'];
|
|
188
|
+
// Per-model Codex reasoning ladders. GPT-5.6 adds the native `max` rung (sol/terra also expose
|
|
189
|
+
// `ultra`), which older Codex models (5.5/5.4) do not support — so the picker must vary by model,
|
|
190
|
+
// never a single flat list. For codex, `max`/`ultra` are REAL CLI reasoning levels (not the
|
|
191
|
+
// claude display alias): they are dispatched verbatim (see splitEffortForAgent / the codex driver).
|
|
192
|
+
const CODEX_56_EFFORTS = {
|
|
193
|
+
'gpt-5.6-sol': [...CODEX_BASE_EFFORTS, 'max', ULTRA_EFFORT],
|
|
194
|
+
'gpt-5.6-terra': [...CODEX_BASE_EFFORTS, 'max', ULTRA_EFFORT],
|
|
195
|
+
'gpt-5.6-luna': [...CODEX_BASE_EFFORTS, 'max'],
|
|
196
|
+
};
|
|
181
197
|
const AGENT_EFFORT_LEVELS = {
|
|
182
198
|
claude: effortLevels('low', 'medium', 'high', 'xhigh', 'max', ULTRA_EFFORT),
|
|
183
|
-
codex: effortLevels(
|
|
199
|
+
codex: effortLevels(...CODEX_BASE_EFFORTS),
|
|
184
200
|
// gemini intentionally has no UI-exposed effort levels: pikiloom sends it no reasoning-effort
|
|
185
201
|
// (see the gemini→null guards in InputComposer). Add a gemini entry here to surface low/high.
|
|
186
202
|
hermes: effortLevels('minimal', 'low', 'medium', 'high', 'xhigh'),
|
|
187
203
|
};
|
|
188
204
|
// Valid effort levels for a given (agent, model, providerKind). Returns [] when reasoning
|
|
189
205
|
// effort does not apply (the UI then hides the selector entirely). model/providerKind are the
|
|
190
|
-
// seam for per-model rules — add them here and nowhere else.
|
|
191
|
-
|
|
206
|
+
// seam for per-model rules — add them here and nowhere else. A known GPT-5.6 codex model unlocks
|
|
207
|
+
// its `max`/`ultra` rungs; every other codex model (incl. BYOK) keeps the base low→xhigh ladder.
|
|
208
|
+
export function effortOptionsFor(agent, model, _providerKind) {
|
|
209
|
+
if (agent === 'codex' && model && model in CODEX_56_EFFORTS) {
|
|
210
|
+
return effortLevels(...CODEX_56_EFFORTS[model]);
|
|
211
|
+
}
|
|
192
212
|
return AGENT_EFFORT_LEVELS[agent] ?? [];
|
|
193
213
|
}
|
|
214
|
+
// Reconcile the two meanings of the effort token before dispatch. Codex (GPT-5.6+) treats
|
|
215
|
+
// `max`/`ultra` as NATIVE reasoning levels, so its token must reach the CLI verbatim — never fold
|
|
216
|
+
// `ultra` into `max`, and never turn on the (claude-only) workflow orchestration. For every other
|
|
217
|
+
// agent `ultra` stays a display alias that decomposes to `max` effort + workflow (decomposeEffortSelection).
|
|
218
|
+
export function splitEffortForAgent(agent, raw) {
|
|
219
|
+
if (agent === 'codex')
|
|
220
|
+
return { effort: trimmed(raw).toLowerCase(), workflow: false };
|
|
221
|
+
return decomposeEffortSelection(raw);
|
|
222
|
+
}
|
|
@@ -7,7 +7,7 @@ import { loadUserConfig, saveUserConfig, applyUserConfig } from '../../core/conf
|
|
|
7
7
|
import { setAgentBoundModelId } from '../../agent/index.js';
|
|
8
8
|
import { getAgentUpdateState, checkAgentLatestVersion, manualAgentUpdate } from '../../agent/auto-update.js';
|
|
9
9
|
import { getDriver, getDriverCapabilities } from '../../agent/driver.js';
|
|
10
|
-
import {
|
|
10
|
+
import { splitEffortForAgent, effortOptionsFor } from '../../core/config/runtime-config.js';
|
|
11
11
|
import { getActiveProfile, getProvider, peekProviderModelList, prefetchProviderModels, } from '../../model/index.js';
|
|
12
12
|
import { DASHBOARD_TIMEOUTS } from '../../core/constants.js';
|
|
13
13
|
import { withTimeoutFallback } from '../../core/utils.js';
|
|
@@ -326,7 +326,6 @@ app.post('/api/runtime-agent', async (c) => {
|
|
|
326
326
|
const targetAgent = body?.agent;
|
|
327
327
|
const model = typeof body?.model === 'string' ? body.model.trim() : '';
|
|
328
328
|
const rawEffort = typeof body?.effort === 'string' ? body.effort.trim().toLowerCase() : '';
|
|
329
|
-
const { effort, workflow: effortWorkflow } = decomposeEffortSelection(rawEffort);
|
|
330
329
|
const hasEffort = rawEffort !== '';
|
|
331
330
|
const botRef = runtime.getBotRef();
|
|
332
331
|
if (defaultAgent != null) {
|
|
@@ -358,6 +357,9 @@ app.post('/api/runtime-agent', async (c) => {
|
|
|
358
357
|
botRef.setModelForAgent(targetAgent, model);
|
|
359
358
|
}
|
|
360
359
|
if (hasEffort) {
|
|
360
|
+
// Codex keeps `max`/`ultra` as native reasoning levels; claude decomposes `ultra` into
|
|
361
|
+
// `max` effort + workflow. splitEffortForAgent reconciles both before we persist the default.
|
|
362
|
+
const { effort, workflow: effortWorkflow } = splitEffortForAgent(targetAgent, rawEffort);
|
|
361
363
|
runtime.runtimePrefs.efforts[targetAgent] = effort;
|
|
362
364
|
runtime.setEffortEnv(targetAgent, effort);
|
|
363
365
|
if (targetAgent === 'claude')
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { getProjectSkillPaths, listSkills, stageSessionFiles, ensureManagedSession, findPikiloomSession, getDriverCapabilities, isPendingSessionId } from '../agent/index.js';
|
|
3
3
|
import { loadUserConfig } from '../core/config/user-config.js';
|
|
4
|
-
import {
|
|
4
|
+
import { splitEffortForAgent } from '../core/config/runtime-config.js';
|
|
5
5
|
import { runtime } from './runtime.js';
|
|
6
6
|
const KNOWN_AGENTS = new Set(['claude', 'codex', 'gemini', 'hermes']);
|
|
7
7
|
function parseGoalSlash(prompt) {
|
|
@@ -78,7 +78,7 @@ export async function queueDashboardSessionTask(request) {
|
|
|
78
78
|
: runtime.getRuntimeDefaultAgent(config);
|
|
79
79
|
const modelId = typeof request.model === 'string' ? request.model.trim() : '';
|
|
80
80
|
const profileId = resolveProfileIdOverride(request.profileId);
|
|
81
|
-
const { effort: splitEffort, workflow: ultraWorkflow } =
|
|
81
|
+
const { effort: splitEffort, workflow: ultraWorkflow } = splitEffortForAgent(resolvedAgent, typeof request.effort === 'string' ? request.effort : '');
|
|
82
82
|
const thinkingEffort = resolvedAgent === 'gemini' ? '' : splitEffort;
|
|
83
83
|
const workflowEnabled = ultraWorkflow || request.workflow === true;
|
|
84
84
|
const goalCmd = parseGoalSlash(request.prompt || '');
|
|
@@ -171,7 +171,7 @@ export function forkDashboardSessionTask(request) {
|
|
|
171
171
|
}
|
|
172
172
|
const modelId = typeof request.model === 'string' ? request.model.trim() : '';
|
|
173
173
|
const profileId = resolveProfileIdOverride(request.profileId);
|
|
174
|
-
const { effort: splitEffort, workflow: ultraWorkflow } =
|
|
174
|
+
const { effort: splitEffort, workflow: ultraWorkflow } = splitEffortForAgent(agent, typeof request.effort === 'string' ? request.effort : '');
|
|
175
175
|
const thinkingEffort = agent === 'gemini' ? '' : splitEffort;
|
|
176
176
|
let prompt = request.prompt;
|
|
177
177
|
const skillResult = prompt ? resolveSkillFromPrompt(request.workdir, prompt) : null;
|
package/package.json
CHANGED
|
@@ -6,6 +6,9 @@ export interface AgentTurnInput {
|
|
|
6
6
|
fork?: {
|
|
7
7
|
anchor?: string | null;
|
|
8
8
|
} | null;
|
|
9
|
+
rewind?: {
|
|
10
|
+
anchor?: string | null;
|
|
11
|
+
} | null;
|
|
9
12
|
workdir: string;
|
|
10
13
|
model?: string | null;
|
|
11
14
|
effort?: string | null;
|
|
@@ -55,6 +58,10 @@ export type DriverEvent = {
|
|
|
55
58
|
} | {
|
|
56
59
|
type: 'activity';
|
|
57
60
|
line: string;
|
|
61
|
+
} | {
|
|
62
|
+
type: 'compaction';
|
|
63
|
+
trigger: 'auto' | 'manual';
|
|
64
|
+
atTokens?: number | null;
|
|
58
65
|
};
|
|
59
66
|
export type SteerFn = (prompt: string, attachments?: string[]) => Promise<boolean>;
|
|
60
67
|
export interface DriverContext {
|
|
@@ -106,6 +113,7 @@ export interface AgentDriver {
|
|
|
106
113
|
resume?: boolean;
|
|
107
114
|
tui?: boolean;
|
|
108
115
|
fork?: boolean;
|
|
116
|
+
rewind?: boolean;
|
|
109
117
|
};
|
|
110
118
|
run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>;
|
|
111
119
|
tui?(input: TuiInput): TuiSpec;
|
|
@@ -25,6 +25,9 @@ export interface CoreSessionRecord {
|
|
|
25
25
|
anchor: string | null;
|
|
26
26
|
mode: 'native' | 'seed';
|
|
27
27
|
} | null;
|
|
28
|
+
pendingRewind?: {
|
|
29
|
+
anchor: string | null;
|
|
30
|
+
} | null;
|
|
28
31
|
}
|
|
29
32
|
export interface SessionStore {
|
|
30
33
|
ensure(agent: string, opts: {
|
|
@@ -48,6 +51,7 @@ export interface SessionStore {
|
|
|
48
51
|
reconcileRunning?(isAlive: (pid: number) => boolean): Promise<number>;
|
|
49
52
|
appendTurn?(agent: string, sessionId: string, turn: UniversalSnapshot): Promise<void>;
|
|
50
53
|
history?(agent: string, sessionId: string): Promise<UniversalSnapshot[]>;
|
|
54
|
+
truncateTurns?(agent: string, sessionId: string, throughTaskId: string | null): Promise<void>;
|
|
51
55
|
}
|
|
52
56
|
export interface ModelInjection {
|
|
53
57
|
model?: string | null;
|
|
@@ -23,6 +23,13 @@ export interface LoomIO {
|
|
|
23
23
|
forkSession(input: ForkSessionInput): Promise<{
|
|
24
24
|
sessionKey: string;
|
|
25
25
|
}>;
|
|
26
|
+
rewindSession(input: {
|
|
27
|
+
sessionKey: string;
|
|
28
|
+
atTaskId: string;
|
|
29
|
+
anchor?: string | null;
|
|
30
|
+
}): Promise<{
|
|
31
|
+
sessionKey: string;
|
|
32
|
+
}>;
|
|
26
33
|
stop(sessionKey: string): boolean;
|
|
27
34
|
steer(taskId: string, prompt: string, attachments?: string[]): Promise<boolean>;
|
|
28
35
|
interact(promptId: string, action: 'select' | 'text' | 'skip' | 'cancel', value?: string): boolean;
|
|
@@ -9,6 +9,7 @@ export declare class ClaudeDriver implements AgentDriver {
|
|
|
9
9
|
resume: boolean;
|
|
10
10
|
tui: boolean;
|
|
11
11
|
fork: boolean;
|
|
12
|
+
rewind: boolean;
|
|
12
13
|
};
|
|
13
14
|
constructor(bin?: string);
|
|
14
15
|
tui(input: TuiInput): TuiSpec;
|
|
@@ -25,6 +26,8 @@ export declare class ClaudeDriver implements AgentDriver {
|
|
|
25
26
|
}
|
|
26
27
|
export declare function claudeResumeArgs(sessionId: string, fork?: {
|
|
27
28
|
anchor?: string | null;
|
|
29
|
+
} | null, rewind?: {
|
|
30
|
+
anchor?: string | null;
|
|
28
31
|
} | null): string[];
|
|
29
32
|
export declare function handleClaudeEvent(ev: any, s: any, emit: (e: DriverEvent) => void): void;
|
|
30
33
|
export declare function claudeBgHoldCapMs(): number;
|
|
@@ -9,7 +9,7 @@ import { attachedFileNote, contextPercent, createLineBuffer, imageMimeForFile, p
|
|
|
9
9
|
export class ClaudeDriver {
|
|
10
10
|
bin;
|
|
11
11
|
id = 'claude';
|
|
12
|
-
capabilities = { steer: true, interact: false, resume: true, tui: true, fork: true };
|
|
12
|
+
capabilities = { steer: true, interact: false, resume: true, tui: true, fork: true, rewind: true };
|
|
13
13
|
constructor(bin = 'claude') {
|
|
14
14
|
this.bin = bin;
|
|
15
15
|
}
|
|
@@ -43,7 +43,7 @@ export class ClaudeDriver {
|
|
|
43
43
|
if (input.effort)
|
|
44
44
|
args.push('--effort', input.effort === 'ultra' ? 'max' : input.effort); // request extended thinking (ultra is a display-only alias for max)
|
|
45
45
|
if (input.sessionId)
|
|
46
|
-
args.push(...claudeResumeArgs(input.sessionId, input.fork));
|
|
46
|
+
args.push(...claudeResumeArgs(input.sessionId, input.fork, input.rewind));
|
|
47
47
|
if (input.systemPrompt)
|
|
48
48
|
args.push('--append-system-prompt', input.systemPrompt);
|
|
49
49
|
if (input.mcpConfigPath)
|
|
@@ -369,14 +369,19 @@ export class ClaudeDriver {
|
|
|
369
369
|
}
|
|
370
370
|
// The --resume flag family for one turn: plain resume appends to the session; a fork branches
|
|
371
371
|
// a NEW session off it (--fork-session), optionally cut at an inclusive keep-boundary record
|
|
372
|
-
// uuid (--resume-session-at)
|
|
373
|
-
|
|
372
|
+
// uuid (--resume-session-at); a rewind cuts at that boundary WITHOUT --fork-session, so claude
|
|
373
|
+
// rebranches the SAME session in place (the transcript is a parentUuid tree — the dropped tip
|
|
374
|
+
// becomes a dead sibling and leaves the active path). Pure so tests pin the arg contract.
|
|
375
|
+
export function claudeResumeArgs(sessionId, fork, rewind) {
|
|
374
376
|
const args = ['--resume', sessionId];
|
|
375
377
|
if (fork) {
|
|
376
378
|
args.push('--fork-session');
|
|
377
379
|
if (fork.anchor)
|
|
378
380
|
args.push('--resume-session-at', fork.anchor);
|
|
379
381
|
}
|
|
382
|
+
else if (rewind?.anchor) {
|
|
383
|
+
args.push('--resume-session-at', rewind.anchor);
|
|
384
|
+
}
|
|
380
385
|
return args;
|
|
381
386
|
}
|
|
382
387
|
function claudeUsageOf(s) {
|
|
@@ -468,6 +473,15 @@ export function handleClaudeEvent(ev, s, emit) {
|
|
|
468
473
|
}
|
|
469
474
|
s.model = ev.model ?? s.model;
|
|
470
475
|
s.contextWindow = claudeEffectiveContextWindow(claudeContextWindowFromModel(s.model)) ?? s.contextWindow;
|
|
476
|
+
// Claude compacted the running context (subtype `compact_boundary`): `trigger` is
|
|
477
|
+
// `auto` (the context filled up) or `manual` (a `/compact` command). Surface it live
|
|
478
|
+
// so a terminal can show a "compacting" affordance; the compacted summary itself lands
|
|
479
|
+
// in the native transcript and settles into a divider separately.
|
|
480
|
+
if (ev.subtype === 'compact_boundary') {
|
|
481
|
+
const meta = (ev.compact_metadata ?? {});
|
|
482
|
+
emit({ type: 'compaction', trigger: meta.trigger === 'manual' ? 'manual' : 'auto', atTokens: typeof meta.pre_tokens === 'number' ? meta.pre_tokens : null });
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
471
485
|
// Live thinking progress (system/thinking_tokens, ~every 1.4s of sustained thinking): during
|
|
472
486
|
// extended thinking a subscription account streams no plaintext (signature_delta only) and no
|
|
473
487
|
// usage until the message settles, so without projecting these the terminal shows a dead
|
|
@@ -320,6 +320,9 @@ export class CodexDriver {
|
|
|
320
320
|
state.sessionId = threadId;
|
|
321
321
|
ctx.emit({ type: 'session', sessionId: threadId });
|
|
322
322
|
}
|
|
323
|
+
// Codex emits no explicit compaction event, so track peak occupancy: a sharp
|
|
324
|
+
// mid-turn drop is an auto-compaction, surfaced as a live `compaction` signal.
|
|
325
|
+
let compactPeakTokens = 0;
|
|
323
326
|
srv.onNotification((method, params) => {
|
|
324
327
|
if (params?.threadId && params.threadId !== state.sessionId && method !== 'turn/started')
|
|
325
328
|
return;
|
|
@@ -444,6 +447,21 @@ export class CodexDriver {
|
|
|
444
447
|
case 'thread/tokenUsage/updated': {
|
|
445
448
|
applyCodexTokenUsage(state, params?.tokenUsage || params?.usage);
|
|
446
449
|
ctx.emit({ type: 'usage', usage: codexUsageOf(state) });
|
|
450
|
+
// No explicit boundary event: a sharp drop from a high peak IS a compaction.
|
|
451
|
+
// Conservative thresholds (peak ≥ 50% of window, drop ≥ 25% of window) so
|
|
452
|
+
// ordinary turn churn never trips it; re-baseline so it fires once per drop.
|
|
453
|
+
{
|
|
454
|
+
const cw = state.contextWindow ?? 0;
|
|
455
|
+
const used = state.contextUsed ?? 0;
|
|
456
|
+
if (cw > 0 && used >= 0) {
|
|
457
|
+
if (used > compactPeakTokens)
|
|
458
|
+
compactPeakTokens = used;
|
|
459
|
+
else if (compactPeakTokens / cw >= 0.5 && (compactPeakTokens - used) / cw >= 0.25) {
|
|
460
|
+
ctx.emit({ type: 'compaction', trigger: 'auto', atTokens: compactPeakTokens });
|
|
461
|
+
compactPeakTokens = used;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
447
465
|
break;
|
|
448
466
|
}
|
|
449
467
|
case 'turn/completed': {
|
|
@@ -32,6 +32,7 @@ export declare class FsSessionStore implements SessionStore {
|
|
|
32
32
|
reconcileRunning(isAlive: (pid: number) => boolean): Promise<number>;
|
|
33
33
|
appendTurn(agent: string, sessionId: string, turn: UniversalSnapshot): Promise<void>;
|
|
34
34
|
history(agent: string, sessionId: string): Promise<UniversalSnapshot[]>;
|
|
35
|
+
truncateTurns(agent: string, sessionId: string, throughTaskId: string | null): Promise<void>;
|
|
35
36
|
}
|
|
36
37
|
/**
|
|
37
38
|
* Is a pid currently a live process? `signal 0` probes without delivering a signal: ESRCH =>
|
|
@@ -145,6 +145,26 @@ export class FsSessionStore {
|
|
|
145
145
|
}
|
|
146
146
|
return out;
|
|
147
147
|
}
|
|
148
|
+
// Rewrite turns.jsonl to the prefix through `throughTaskId` inclusive (in-place rewind). null
|
|
149
|
+
// clears the transcript; an unknown id keeps everything (no-op) so a stale cut can't wipe
|
|
150
|
+
// history. Atomic (temp file + rename) so a crash can't leave a half-written store.
|
|
151
|
+
async truncateTurns(agent, sessionId, throughTaskId) {
|
|
152
|
+
const all = await this.history(agent, sessionId);
|
|
153
|
+
let kept;
|
|
154
|
+
if (throughTaskId == null)
|
|
155
|
+
kept = [];
|
|
156
|
+
else {
|
|
157
|
+
const cut = all.findIndex((t) => t.taskId === throughTaskId);
|
|
158
|
+
if (cut < 0)
|
|
159
|
+
return; // unknown cut — leave the transcript untouched
|
|
160
|
+
kept = all.slice(0, cut + 1);
|
|
161
|
+
}
|
|
162
|
+
const p = this.turnsPath(agent, sessionId);
|
|
163
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
164
|
+
const tmp = `${p}.tmp-${process.pid}`;
|
|
165
|
+
fs.writeFileSync(tmp, kept.map((t) => JSON.stringify(t)).join('\n') + (kept.length ? '\n' : ''));
|
|
166
|
+
fs.renameSync(tmp, p);
|
|
167
|
+
}
|
|
148
168
|
}
|
|
149
169
|
/**
|
|
150
170
|
* Is a pid currently a live process? `signal 0` probes without delivering a signal: ESRCH =>
|
|
@@ -86,6 +86,14 @@ export interface UniversalSnapshot {
|
|
|
86
86
|
toolCalls?: UniversalToolCall[];
|
|
87
87
|
subAgents?: UniversalSubAgent[];
|
|
88
88
|
usage?: UniversalUsage | null;
|
|
89
|
+
/** Set when the CLI compacted the context mid-turn (its `compact_boundary`
|
|
90
|
+
* event). `trigger` separates an automatic (context-full) compaction from a
|
|
91
|
+
* manual `/compact`. Drives a live "compacting" affordance; because a runner
|
|
92
|
+
* owns one turn, it naturally clears on the next turn. */
|
|
93
|
+
compaction?: {
|
|
94
|
+
trigger: 'auto' | 'manual';
|
|
95
|
+
atTokens?: number | null;
|
|
96
|
+
} | null;
|
|
89
97
|
artifacts?: UniversalArtifact[];
|
|
90
98
|
interactions?: UniversalInteraction[];
|
|
91
99
|
queued?: UniversalQueuedTask[];
|
|
@@ -46,6 +46,13 @@ export declare class Hub implements LoomIO {
|
|
|
46
46
|
forkSession(input: ForkSessionInput): Promise<{
|
|
47
47
|
sessionKey: string;
|
|
48
48
|
}>;
|
|
49
|
+
rewindSession(input: {
|
|
50
|
+
sessionKey: string;
|
|
51
|
+
atTaskId: string;
|
|
52
|
+
anchor?: string | null;
|
|
53
|
+
}): Promise<{
|
|
54
|
+
sessionKey: string;
|
|
55
|
+
}>;
|
|
49
56
|
private enqueue;
|
|
50
57
|
private runNow;
|
|
51
58
|
private promoteNext;
|
|
@@ -143,6 +143,52 @@ export class Hub {
|
|
|
143
143
|
this.deps.log?.(`[hub] forked ${input.fromSessionKey} -> ${agent}:${newId} (${mode}${anchor ? `, anchor ${anchor}` : ''}, ${kept.length}/${turns.length} turns)`);
|
|
144
144
|
return { sessionKey: makeSessionKey(agent, newId) };
|
|
145
145
|
}
|
|
146
|
+
// Rewind `sessionKey` IN PLACE to a turn boundary: drop the transcript after `atTaskId` (the
|
|
147
|
+
// last KEPT turn) and stamp a pendingRewind the next prompt() consumes (resume the SAME native
|
|
148
|
+
// session at the kept boundary, NO fork). The session id — managed AND native — is unchanged;
|
|
149
|
+
// only the dropped tip leaves the active path. Requires a rewind-capable driver, a store that
|
|
150
|
+
// can truncate, and a resolvable native anchor at the cut; otherwise throws so the caller can
|
|
151
|
+
// fall back (append on the same session, or a fork).
|
|
152
|
+
async rewindSession(input) {
|
|
153
|
+
const { agent, sessionId } = splitSessionKey(input.sessionKey);
|
|
154
|
+
const driver = this.deps.drivers.get(agent);
|
|
155
|
+
if (!driver)
|
|
156
|
+
throw new Error(`No driver registered for agent "${agent}"`);
|
|
157
|
+
if (!driver.capabilities?.rewind)
|
|
158
|
+
throw new Error(`agent "${agent}" has no in-place rewind`);
|
|
159
|
+
if (!this.deps.sessionStore.truncateTurns || !this.deps.sessionStore.history) {
|
|
160
|
+
throw new Error('session store cannot rewind (no truncateTurns/history)');
|
|
161
|
+
}
|
|
162
|
+
const rec = await this.deps.sessionStore.get(agent, sessionId);
|
|
163
|
+
if (!rec)
|
|
164
|
+
throw new Error(`rewind: session ${input.sessionKey} not found`);
|
|
165
|
+
const nativeId = rec.nativeSessionId || sessionId;
|
|
166
|
+
const turns = await this.deps.sessionStore.history(agent, sessionId).catch(() => []);
|
|
167
|
+
const cut = turns.findIndex((t) => t.taskId === input.atTaskId);
|
|
168
|
+
if (cut < 0)
|
|
169
|
+
throw new Error(`rewind point ${input.atTaskId} not found in ${input.sessionKey}`);
|
|
170
|
+
if (cut === turns.length - 1)
|
|
171
|
+
throw new Error(`rewind point ${input.atTaskId} is the tail — nothing to regenerate`);
|
|
172
|
+
const kept = turns.slice(0, cut + 1);
|
|
173
|
+
// Native rebranch boundary: explicit override → the kept tail's recorded anchor. No anchor =
|
|
174
|
+
// the kept turn never recorded one (e.g. a native-only import) — can't rebranch, so refuse.
|
|
175
|
+
const anchor = input.anchor ?? kept[kept.length - 1]?.anchor ?? null;
|
|
176
|
+
if (!anchor)
|
|
177
|
+
throw new Error(`rewind: no native anchor at ${input.atTaskId} in ${input.sessionKey}`);
|
|
178
|
+
// Drop the tip from the managed transcript, then stamp the rewind intent. The native store is
|
|
179
|
+
// untouched here — the next dispatch rebranches it at `anchor` via AgentTurnInput.rewind.
|
|
180
|
+
await this.deps.sessionStore.truncateTurns(agent, sessionId, input.atTaskId);
|
|
181
|
+
const last = kept[kept.length - 1];
|
|
182
|
+
rec.model = last?.model ?? rec.model ?? null;
|
|
183
|
+
rec.effort = last?.effort ?? rec.effort ?? null;
|
|
184
|
+
rec.nativeSessionId = nativeId;
|
|
185
|
+
rec.runState = 'completed';
|
|
186
|
+
rec.runDetail = null;
|
|
187
|
+
rec.pendingRewind = { anchor };
|
|
188
|
+
await this.deps.sessionStore.save(rec);
|
|
189
|
+
this.deps.log?.(`[hub] rewound ${input.sessionKey} to ${input.atTaskId} (anchor ${anchor}, kept ${kept.length}/${turns.length} turns)`);
|
|
190
|
+
return { sessionKey: input.sessionKey };
|
|
191
|
+
}
|
|
146
192
|
enqueue(item) {
|
|
147
193
|
const q = this.waiting.get(item.sessionKey) ?? [];
|
|
148
194
|
q.push(item);
|
|
@@ -174,6 +220,9 @@ export class Hub {
|
|
|
174
220
|
// first turn keeps the intent, so the retry forks again). Native mode resumes the PARENT's
|
|
175
221
|
// native id with the fork flag; seed mode starts fresh and replays the copied transcript.
|
|
176
222
|
const pendingFork = rec && !rec.nativeSessionId ? rec.pendingFork ?? null : null;
|
|
223
|
+
// A rewind keeps the SAME native id (it resumes+rebranches this session), so — unlike a fork —
|
|
224
|
+
// it is NOT gated on a missing native id. Fork and rewind never coexist on one record.
|
|
225
|
+
const pendingRewind = rec?.pendingRewind ?? null;
|
|
177
226
|
const injection = await this.deps.modelResolver.resolve(agent, { model: input.model, profileId: null }).catch(() => null);
|
|
178
227
|
const model = injection?.model ?? input.model ?? null;
|
|
179
228
|
const effort = input.effort ?? null;
|
|
@@ -191,6 +240,7 @@ export class Hub {
|
|
|
191
240
|
const systemPrompt = await this.composeSystemPrompt(agent, workdir, !preExisted || pendingFork?.mode === 'seed');
|
|
192
241
|
let driverSessionId = rec?.nativeSessionId || (input.sessionKey ? sessionId : null);
|
|
193
242
|
let driverFork = null;
|
|
243
|
+
let driverRewind = null;
|
|
194
244
|
let driverPrompt = input.prompt;
|
|
195
245
|
if (pendingFork) {
|
|
196
246
|
if (pendingFork.mode === 'native' && pendingFork.parentNativeSessionId) {
|
|
@@ -204,11 +254,18 @@ export class Hub {
|
|
|
204
254
|
driverPrompt = `${seed}\n\n${input.prompt}`;
|
|
205
255
|
}
|
|
206
256
|
}
|
|
257
|
+
else if (pendingRewind) {
|
|
258
|
+
// In-place rewind: resume THIS session's own native id but rebranch at the kept boundary
|
|
259
|
+
// (no fork), so the dropped tip leaves the active path and the prompt regenerates from it.
|
|
260
|
+
driverSessionId = rec?.nativeSessionId || sessionId;
|
|
261
|
+
driverRewind = { anchor: pendingRewind.anchor };
|
|
262
|
+
}
|
|
207
263
|
const turnInput = {
|
|
208
264
|
prompt: driverPrompt,
|
|
209
265
|
attachments: input.attachments,
|
|
210
266
|
sessionId: driverSessionId,
|
|
211
267
|
fork: driverFork,
|
|
268
|
+
rewind: driverRewind,
|
|
212
269
|
workdir,
|
|
213
270
|
model, effort, systemPrompt,
|
|
214
271
|
env: spawn.env,
|
|
@@ -239,6 +296,20 @@ export class Hub {
|
|
|
239
296
|
this.deps.log?.(`[hub] pendingFork clear failed ${sessionKey}: ${e?.message || e}`);
|
|
240
297
|
}
|
|
241
298
|
}
|
|
299
|
+
// A rewind materialized (the turn ran and rebranched the native session) — consume the
|
|
300
|
+
// intent so a normal follow-up prompt appends to the regenerated tip instead of rewinding.
|
|
301
|
+
if (pendingRewind && result.sessionId) {
|
|
302
|
+
try {
|
|
303
|
+
const settled = await this.deps.sessionStore.get(agent, sessionId);
|
|
304
|
+
if (settled?.pendingRewind) {
|
|
305
|
+
settled.pendingRewind = null;
|
|
306
|
+
await this.deps.sessionStore.save(settled);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
catch (e) {
|
|
310
|
+
this.deps.log?.(`[hub] pendingRewind clear failed ${sessionKey}: ${e?.message || e}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
242
313
|
// Persist the completed turn's final snapshot as a transcript entry. The runner's
|
|
243
314
|
// snapshot is stable once done (a follow-up prompt spawns a fresh runner+snapshot).
|
|
244
315
|
try {
|
|
@@ -96,6 +96,9 @@ export class SessionRunner {
|
|
|
96
96
|
case 'usage':
|
|
97
97
|
this.snapshot.usage = mergeUsage(this.snapshot.usage, e.usage);
|
|
98
98
|
break;
|
|
99
|
+
case 'compaction':
|
|
100
|
+
this.snapshot.compaction = { trigger: e.trigger, atTokens: e.atTokens ?? null };
|
|
101
|
+
break;
|
|
99
102
|
// toolCalls is the structured SSOT; activity is its derived human-readable view.
|
|
100
103
|
// A driver that streams explicit `activity` lines (e.g. echo) owns activity directly;
|
|
101
104
|
// any driver that emits structured tool/subagent events gets the projection for free.
|