You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by zh...@apache.org on 2026/10/19 10:20:06 UTC
(maka) 01/01: feat(cli): expose running-session recaps
This is an automated email from the ASF dual-hosted git repository.
me2seeks pushed a commit to branch feat/cli-session-recap
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 8c215df7fac9d2677e357ac876ef5fd16b979a13
Author: me2seeks <me...@gmail.com>
AuthorDate: Mon Oct 19 18:19:20 2026 +0800
feat(cli): expose running-session recaps
Let external callers generate the existing independent Session recap while a Turn continues running. Keep recap text separate from final output and preserve observer cancellation and task permissions.
Generated-by: Codex
---
packages/cli/README.md | 18 ++
packages/cli/README.zh-CN.md | 16 ++
packages/cli/src/__tests__/run-command.test.ts | 76 ++++++
.../cli/src/__tests__/run-session-recap.test.ts | 275 +++++++++++++++++++++
packages/cli/src/run-command-core.ts | 22 +-
packages/cli/src/run-session-command.ts | 16 +-
packages/cli/src/run-session-result.ts | 2 +
7 files changed, 420 insertions(+), 5 deletions(-)
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 9ff39828ec..17f055dfe9 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -132,6 +132,7 @@ Session ID to observe the eventual result. Given a returned Session ID in `$sess
```sh
maka run --session "$session" --status
+maka run --session "$session" --recap --json
maka run --session "$session" --wait --json
maka run --session "$session" --answer '["Yes",null]' --wait --json
maka run --session "$session" "Inspect the tests next" --queue --wait --json
@@ -140,6 +141,23 @@ maka run --session "$session" --result --json
maka run --session "$session" --resume --wait --json
```
+`--recap` generates a one-sentence summary of recorded Session progress using the same
+independent model call as TUI `/recap`. It works during an active Turn without steering,
+queueing, stopping, or waiting for that Turn to finish. Without `--json` it prints the summary;
+with `--json` it returns `sessionId`, current `status`, and a separate `recap` field, for example:
+
+```json
+{"sessionId":"…","status":"running","queued":0,"recap":"You asked for a review; the parser is checked and test verification is still pending."}
+```
+
+The recap is a snapshot of the history captured when generation starts. It cannot see unrecorded
+work or results from a tool that has not returned, and execution can advance while it is generated.
+Each invocation requests a fresh summary and consumes model usage; `--status` remains the cheap
+status query. A recap is not the final answer (`--result`) or a reply from the running agent.
+Do not combine `--recap` with a prompt, another Session action, `--wait`, or `--no-wait`.
+Successful recap generation exits 0 even if the Session's latest Turn failed; generation errors
+exit 1. Ctrl+C cancels the CLI observer and leaves Host execution running.
+
Session controls (`--session`) inherit the Host's saved permissions, just as they inherit the
model and working directory. Continuing, queueing, steering, or answering does not require another
`--yolo` and does not change the boundary. Add `--yolo` only to explicitly grant full access.
diff --git a/packages/cli/README.zh-CN.md b/packages/cli/README.zh-CN.md
index 0582801788..46ef71b002 100644
--- a/packages/cli/README.zh-CN.md
+++ b/packages/cli/README.zh-CN.md
@@ -120,6 +120,7 @@ maka run "检查这个项目" --no-wait
```sh
maka run --session "$session" --status
+maka run --session "$session" --recap --json
maka run --session "$session" --wait --json
maka run --session "$session" --answer '["是",null]' --wait --json
maka run --session "$session" "接下来检查测试" --queue --wait --json
@@ -128,6 +129,21 @@ maka run --session "$session" --result --json
maka run --session "$session" --resume --wait --json
```
+`--recap` 复用 TUI `/recap` 的独立模型调用,用一句话总结会话已记录的进展。
+它可以在 Turn 运行中使用,不会 steering、排队、停止当前 Turn,也不等 Turn 完成。
+不加 `--json` 时直接输出摘要;加上后返回 `sessionId`、当前 `status` 和独立的 `recap` 字段,例如:
+
+```json
+{"sessionId":"…","status":"running","queued":0,"recap":"你要求审查代码;参数解析已检查,测试验证尚未完成。"}
+```
+
+摘要基于生成开始时读取的历史快照,无法看到尚未记录的工作或尚未返回的工具结果;
+生成期间任务可能继续推进。每次调用都会重新请求模型并产生模型用量;只查状态继续用
+`--status`。摘要不是最终答案(`--result`),也不是正在运行的代理直接回复的消息。
+`--recap` 不能与 prompt、其他会话动作、`--wait` 或 `--no-wait` 组合使用。
+摘要生成成功返回退出码 0,即使会话最近的 Turn 失败;生成失败返回退出码 1。
+Ctrl+C 取消 CLI 观察,不会停止 Host 中的执行。
+
会话控制(`--session`)与继承模型、工作目录一样,继承 Host 已保存的权限。续跑、queue、
steer 和回答问题不需要重复 `--yolo`,也不会改变执行边界;只有要显式授予完整权限时才加
`--yolo`。`--status` 会报告 `executionBoundary`:受管 `read_only`/`writable`、bypass 或 external。
diff --git a/packages/cli/src/__tests__/run-command.test.ts b/packages/cli/src/__tests__/run-command.test.ts
index c28700d991..a0ac238756 100644
--- a/packages/cli/src/__tests__/run-command.test.ts
+++ b/packages/cli/src/__tests__/run-command.test.ts
@@ -109,6 +109,82 @@ describe('maka run argument parsing', () => {
assert.deepEqual(JSON.parse(stdout.join('')), { sessionId: 's', status: 'running' });
});
+ test('recap selects one Session action and rejects execution and wait combinations', () => {
+ assert.deepEqual(parseMakaRunArgs(['--session', 's', '--recap', '--json']), {
+ kind: 'run',
+ options: { sessionId: 's', recap: true, json: true, stdinPrompt: false },
+ });
+ assert.equal(parseMakaRunArgs(['--recap']).kind, 'error');
+ for (const extra of [
+ ['prompt'],
+ ['-'],
+ ['--status'],
+ ['--result'],
+ ['--stop'],
+ ['--resume'],
+ ['--approve'],
+ ['--deny'],
+ ['--answer', '["Yes"]'],
+ ['--steer', 'prompt'],
+ ['--queue', 'prompt'],
+ ['--wait'],
+ ['--no-wait'],
+ ])
+ assert.equal(
+ parseMakaRunArgs(['--session', 's', '--recap', ...extra]).kind,
+ 'error',
+ extra.join(' '),
+ );
+ });
+
+ test('recap prints text or JSON without reading stdin or returning the Turn failure code', async () => {
+ for (const json of [false, true]) {
+ const stdout: string[] = [];
+ const result = {
+ sessionId: 's',
+ status: 'failed' as const,
+ recap: 'The review found a bug.',
+ };
+ const exit = await runMakaTextCliCore(
+ ['--session', 's', '--recap', ...(json ? ['--json'] : [])],
+ {
+ listSessions: async () => assert.fail('must not select a Session'),
+ createContext: async () => assert.fail('must not prepare execution'),
+ runSession: async (options) => {
+ assert.equal(options.recap, true);
+ return result;
+ },
+ },
+ {
+ stdinIsTTY: () => false,
+ readStdin: async () => assert.fail('recap must not read stdin'),
+ writeStdout: (text) => stdout.push(text),
+ },
+ );
+ assert.equal(exit, 0);
+ assert.equal(stdout.join(''), json ? `${JSON.stringify(result)}\n` : `${result.recap}\n`);
+ }
+ });
+
+ test('recap generation errors exit nonzero without printing a task result', async () => {
+ const stdout: string[] = [];
+ const stderr: string[] = [];
+ const exit = await runMakaTextCliCore(
+ ['--session', 's', '--recap', '--json'],
+ {
+ listSessions: async () => [],
+ createContext: async () => assert.fail('must not prepare execution'),
+ runSession: async () => {
+ throw new Error('Session recap failed: provider');
+ },
+ },
+ { writeStdout: (text) => stdout.push(text), writeStderr: (text) => stderr.push(text) },
+ );
+ assert.equal(exit, 1);
+ assert.deepEqual(stdout, []);
+ assert.match(stderr.join(''), /Session recap failed: provider/);
+ });
+
test('completed status and stop receipts remain visible without --json', async () => {
for (const flags of [[], ['--status'], ['--stop']]) {
const stdout: string[] = [];
diff --git a/packages/cli/src/__tests__/run-session-recap.test.ts b/packages/cli/src/__tests__/run-session-recap.test.ts
new file mode 100644
index 0000000000..e9a302ca90
--- /dev/null
+++ b/packages/cli/src/__tests__/run-session-recap.test.ts
@@ -0,0 +1,275 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { spawn, type ChildProcess } from 'node:child_process';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { createServer } from 'node:http';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { test } from 'node:test';
+import { fileURLToPath } from 'node:url';
+import { deferred, waitFor } from '@maka/core/test-only/async-primitives';
+import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores';
+import { openInteractiveRuntimePolicyStoresForWrite } from '@maka/storage/runtime-policy-stores';
+import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority';
+import { FakeBackend, FAKE_HOLD_OPEN_PROMPT } from '@maka/runtime/test-only/fake-backend';
+import { connectRuntimeHost, type RuntimeHostConnection } from '@maka/runtime-host/client';
+import { RUNTIME_HOST_PROTOCOL_VERSION } from '@maka/runtime-host/protocol';
+import { startExecutionRuntimeHostService } from '@maka/runtime-host/server';
+
+const fixturePath = fileURLToPath(new URL('./run-session-process-fixture.js', import.meta.url));
+
+test('external CLI recap reads active progress without steering or waiting for the Turn', {
+ timeout: 90_000,
+}, async (t) => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-cli-recap-'));
+ const children = new Set<ChildProcess>();
+ let host: Awaited<ReturnType<typeof startExecutionRuntimeHostService>> | undefined;
+ let connection: RuntimeHostConnection | undefined;
+ const recapText = 'You asked for a review; the agent is still working on it.';
+ const requests: string[] = [];
+ let requestArrived = deferred<void>();
+ let releaseResponse = deferred<void>();
+ let emptyResponse = false;
+ const provider = createServer((request, response) => {
+ void (async () => {
+ let body = '';
+ for await (const chunk of request) body += chunk;
+ requests.push(body);
+ requestArrived.resolve();
+ await releaseResponse.promise;
+ response.setHeader('content-type', 'application/json');
+ response.end(
+ JSON.stringify({
+ id: 'recap-response',
+ object: 'chat.completion',
+ created: 1,
+ model: 'fake-model',
+ choices: [
+ {
+ index: 0,
+ message: { role: 'assistant', content: emptyResponse ? '' : recapText },
+ finish_reason: 'stop',
+ },
+ ],
+ usage: { prompt_tokens: 30, completion_tokens: 15, total_tokens: 45 },
+ }),
+ );
+ })().catch((error) => response.destroy(error as Error));
+ });
+ t.after(async () => {
+ releaseResponse.resolve();
+ for (const child of children) child.kill('SIGTERM');
+ try {
+ await connection?.close();
+ await host?.close();
+ } finally {
+ provider.closeAllConnections();
+ await new Promise<void>((resolve, reject) =>
+ provider.close((error) => (error ? reject(error) : resolve())),
+ );
+ await rm(root, { recursive: true, force: true });
+ }
+ });
+ await new Promise<void>((resolve) => provider.listen(0, '127.0.0.1', resolve));
+ const address = provider.address();
+ assert.ok(address && typeof address === 'object');
+ const capability = await resolveStorageRoot({ path: root, kind: 'interactive' });
+ const owner = await tryAcquireInteractiveRootOwner(capability);
+ assert.ok(owner);
+ let sessionId: string;
+ try {
+ const policy = await openInteractiveRuntimePolicyStoresForWrite(owner.lease);
+ const created = await policy.connectionCatalog.create({
+ expectedCatalogRevision: 0,
+ connection: {
+ slug: 'fake',
+ name: 'Fake',
+ providerType: 'ollama',
+ baseUrl: `http://127.0.0.1:${address.port}/v1`,
+ enabled: true,
+ enabledModelIds: ['fake-model'],
+ },
+ });
+ assert.equal(created.kind, 'committed');
+ if (created.kind !== 'committed') throw new Error('Connection setup failed');
+ const connectionId = created.snapshot.connections[0]!.connectionId;
+ const fetch = await policy.operations.beginModelFetch(connectionId);
+ assert.equal(fetch.kind, 'ready');
+ if (fetch.kind !== 'ready') throw new Error('Model setup failed');
+ assert.equal(
+ (
+ await policy.operations.completeModelFetch(fetch.ticket, {
+ models: [{ id: 'fake-model' }],
+ source: 'fetched',
+ fetchedAt: Date.now(),
+ })
+ ).kind,
+ 'committed',
+ );
+ const stores = await openInteractiveExecutionStoresForWrite(owner.lease);
+ try {
+ sessionId = (
+ await stores.sessionStore.create({
+ cwd: root,
+ llmConnectionId: connectionId,
+ llmConnectionSlug: 'fake',
+ model: 'fake-model',
+ permissionMode: 'ask',
+ })
+ ).id;
+ } finally {
+ await stores.sessionStore.close?.();
+ }
+ } finally {
+ await owner.close();
+ }
+ const { createExecutionRuntimeHostComposition } = await import(
+ new URL('./execution-composition.js', import.meta.resolve('@maka/runtime-host/server')).href
+ );
+ host = await startExecutionRuntimeHostService(
+ { rootPath: root },
+ {
+ createComposition: (context, options) =>
+ createExecutionRuntimeHostComposition(
+ context,
+ { ...options, bootstrapRuntimePolicy: false },
+ {
+ primaryBackendFactory: (backendContext: ConstructorParameters<typeof FakeBackend>[0]) =>
+ new FakeBackend(backendContext),
+ generateSessionTitle: async () => undefined,
+ },
+ ),
+ },
+ );
+ const connected = await connectRuntimeHost({
+ rootPath: root,
+ protocol: { min: RUNTIME_HOST_PROTOCOL_VERSION, max: RUNTIME_HOST_PROTOCOL_VERSION },
+ });
+ assert.equal(connected.kind, 'connected');
+ if (connected.kind !== 'connected') throw new Error('Unable to connect to Host');
+ const probe = connected.connection;
+ connection = probe;
+ const readSnapshot = async () => {
+ const subscription = await probe.openSessionSubscription({
+ sessionId,
+ transcript: { kind: 'none' },
+ });
+ try {
+ await subscription.ready();
+ return {
+ snapshot: subscription.snapshot,
+ activeStreams: subscription.activeAssistantStreams.length,
+ };
+ } finally {
+ await subscription.close();
+ }
+ };
+ const cli = (...args: string[]) => {
+ const child = spawn(process.execPath, [fixturePath, root, '--session', sessionId, ...args], {
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ children.add(child);
+ let stdout = '';
+ let stderr = '';
+ child.stdout!.setEncoding('utf8').on('data', (data) => {
+ stdout += data;
+ });
+ child.stderr!.setEncoding('utf8').on('data', (data) => {
+ stderr += data;
+ });
+ const result = new Promise<{ code: number | null; stdout: string; stderr: string }>(
+ (resolve, reject) => {
+ child.on('error', reject);
+ child.on('close', (code) => {
+ children.delete(child);
+ resolve({ code, stdout, stderr });
+ });
+ },
+ );
+ return { child, result };
+ };
+ const started = await cli(FAKE_HOLD_OPEN_PROMPT, '--no-wait', '--json').result;
+ assert.equal(started.code, 0, started.stderr);
+ await waitFor(
+ async () => {
+ const current = await readSnapshot();
+ return current.snapshot.rootTurn?.status === 'running' && current.activeStreams > 0;
+ },
+ { timeoutMs: 10_000, pollMs: 10 },
+ );
+ const turn = (await readSnapshot()).snapshot.rootTurn!;
+ const boundary = await probe.request('session.execution_boundary.query', { sessionId });
+ const pendingRecap = cli('--recap', '--json').result;
+ await requestArrived.promise;
+ const during = await cli('--status', '--json').result;
+ assert.equal(during.code, 0, during.stderr);
+ assert.equal(JSON.parse(during.stdout).status, 'running');
+ releaseResponse.resolve();
+ const recap = await pendingRecap;
+ assert.equal(recap.code, 0, recap.stderr);
+ assert.deepEqual(JSON.parse(recap.stdout), {
+ sessionId,
+ status: 'running',
+ queued: 0,
+ recap: recapText,
+ });
+ assert.match(requests[0]!, /Fake backend waiting/);
+ assert.match(requests[0]!, /ONE sentence/);
+ const afterRecap = (await readSnapshot()).snapshot;
+ assert.equal(afterRecap.rootTurn?.turnId, turn.turnId);
+ assert.equal(afterRecap.rootTurn?.runId, turn.runId);
+ assert.equal(afterRecap.rootTurn?.status, 'running');
+ assert.deepEqual(afterRecap.queue.followup, []);
+ assert.deepEqual(afterRecap.queue.steering, []);
+ assert.deepEqual(
+ await probe.request('session.execution_boundary.query', { sessionId }),
+ boundary,
+ );
+ const plain = await cli('--recap').result;
+ assert.equal(plain.code, 0, plain.stderr);
+ assert.equal(plain.stdout, `${recapText}\n`);
+ assert.equal(requests.length, 2, 'each invocation requests a fresh recap');
+ emptyResponse = true;
+ const failed = await cli('--recap', '--json').result;
+ assert.equal(failed.code, 1);
+ assert.equal(failed.stdout, '');
+ assert.match(failed.stderr, /Session recap failed: empty_response/);
+ assert.equal((await readSnapshot()).snapshot.rootTurn?.status, 'running');
+ emptyResponse = false;
+ requestArrived = deferred<void>();
+ releaseResponse = deferred<void>();
+ const interrupted = cli('--recap', '--json');
+ await requestArrived.promise;
+ interrupted.child.kill('SIGINT');
+ const cancelled = await interrupted.result;
+ assert.equal(cancelled.code, 130, cancelled.stderr);
+ assert.equal(cancelled.stdout, '');
+ assert.equal((await readSnapshot()).snapshot.rootTurn?.status, 'running');
+ releaseResponse.resolve();
+ await cli('--stop', '--json').result;
+ await waitFor(async () => (await readSnapshot()).snapshot.rootTurn?.status === 'cancelled', {
+ timeoutMs: 10_000,
+ });
+ const stopped = await cli('--recap', '--json').result;
+ assert.equal(stopped.code, 0, stopped.stderr);
+ assert.equal(JSON.parse(stopped.stdout).status, 'failed');
+ assert.equal(JSON.parse(stopped.stdout).recap, recapText);
+});
diff --git a/packages/cli/src/run-command-core.ts b/packages/cli/src/run-command-core.ts
index 4cff9c61b1..8749e49b19 100644
--- a/packages/cli/src/run-command-core.ts
+++ b/packages/cli/src/run-command-core.ts
@@ -43,6 +43,7 @@ export interface MakaRunOptions {
json?: true;
status?: true;
result?: true;
+ recap?: true;
resumeExecution?: true;
stop?: true;
sandboxDecision?: 'allow' | 'deny';
@@ -160,6 +161,7 @@ const BOOLEAN_FLAGS = new Set([
'json',
'status',
'result',
+ 'recap',
'queue',
'steer',
'stop',
@@ -222,6 +224,7 @@ export function parseMakaRunArgs(argv: readonly string[]): ParseMakaRunArgsResul
const actions =
Number(booleanFlags.has('status')) +
Number(booleanFlags.has('result')) +
+ Number(booleanFlags.has('recap')) +
Number(flags.has('answer')) +
Number(resumeExecution) +
Number(booleanFlags.has('stop')) +
@@ -230,7 +233,8 @@ export function parseMakaRunArgs(argv: readonly string[]): ParseMakaRunArgsResul
if (actions > 1)
return {
kind: 'error',
- message: 'choose one of --status, --result, --answer, --approve, --deny, --resume or --stop',
+ message:
+ 'choose one of --status, --result, --recap, --answer, --approve, --deny, --resume or --stop',
};
if ((booleanFlags.has('approve') || booleanFlags.has('deny')) && booleanFlags.has('yolo'))
return {
@@ -253,10 +257,16 @@ export function parseMakaRunArgs(argv: readonly string[]): ParseMakaRunArgsResul
return { kind: 'error', message: '--queue and --steer require a prompt' };
}
if (
- (booleanFlags.has('status') || booleanFlags.has('result') || booleanFlags.has('stop')) &&
+ (booleanFlags.has('status') ||
+ booleanFlags.has('result') ||
+ booleanFlags.has('recap') ||
+ booleanFlags.has('stop')) &&
(booleanFlags.has('wait') || booleanFlags.has('no-wait'))
) {
- return { kind: 'error', message: '--status, --result and --stop do not accept wait options' };
+ return {
+ kind: 'error',
+ message: '--status, --result, --recap and --stop do not accept wait options',
+ };
}
if (booleanFlags.has('graph') && booleanFlags.has('no-wait')) {
return { kind: 'error', message: '--graph must wait for its final result' };
@@ -318,6 +328,7 @@ export function parseMakaRunArgs(argv: readonly string[]): ParseMakaRunArgsResul
...(booleanFlags.has('json') ? { json: true as const } : {}),
...(booleanFlags.has('status') ? { status: true as const } : {}),
...(booleanFlags.has('result') ? { result: true as const } : {}),
+ ...(booleanFlags.has('recap') ? { recap: true as const } : {}),
...(resumeExecution ? { resumeExecution: true as const } : {}),
...(booleanFlags.has('stop') ? { stop: true as const } : {}),
...(booleanFlags.has('approve')
@@ -624,7 +635,8 @@ export function writeSessionResult(
deps: Pick<MakaRunDeps, 'writeStdout' | 'writeStderr'>,
json: boolean,
): void {
- if (json || result.status !== 'completed' || result.finalOutput === undefined)
+ if (!json && result.recap !== undefined) deps.writeStdout(withTrailingNewline(result.recap));
+ else if (json || result.status !== 'completed' || result.finalOutput === undefined)
deps.writeStdout(`${JSON.stringify(result)}\n`);
else deps.writeStdout(withTrailingNewline(result.finalOutput));
if (result.status === 'failed' && result.error) deps.writeStderr(`maka run: ${result.error}\n`);
@@ -671,6 +683,7 @@ function makaRunHelpText(cliCommand: string): string {
' --no-wait Submit and return (Host-owned execution only)',
' --status Read current Session status without executing',
' --result Read the latest Turn result without waiting',
+ ' --recap Generate a one-sentence progress recap, even during execution',
' --stop Request stop for the current Turn (no --yolo needed)',
' --answer <json-array> Answer the current AskUser, e.g. ["Yes",null]',
' --approve Approve the single pending sandbox expansion',
@@ -686,6 +699,7 @@ function makaRunHelpText(cliCommand: string): string {
'',
'Session controls inherit saved permissions; --yolo explicitly grants full access.',
'--status reports the execution boundary; a read-only prompt does not restrict permissions.',
+ '--recap makes a separate model call over recorded progress; it does not steer or wait for the Turn.',
'Legacy --resume <id> still requires --yolo for a full-access Session.',
'--approve/--deny decide a sandbox request without changing the Session to full access.',
].join('\n');
diff --git a/packages/cli/src/run-session-command.ts b/packages/cli/src/run-session-command.ts
index 1549fd1f35..6481f4e6fb 100644
--- a/packages/cli/src/run-session-command.ts
+++ b/packages/cli/src/run-session-command.ts
@@ -19,7 +19,7 @@
import { isInteractionAnswerValidForRequest } from '@maka/core/interaction';
import type { StoredMessage } from '@maka/core/session';
-import type { RuntimeHostConnection } from '@maka/runtime-host/client';
+import { abortable, type RuntimeHostConnection } from '@maka/runtime-host/client';
import { updateRuntimeHostSession } from './runtime-host-session-update.js';
import type {
InteractionPendingSnapshot,
@@ -80,6 +80,20 @@ export async function runMakaSessionCommand(
try {
assertActive();
let target = observer.snapshot.rootTurn?.turnId;
+ if (options.recap) {
+ const result = await abortable(
+ () =>
+ connection.request('session.recap.generate', {
+ sessionId,
+ effectId: env.newId(),
+ reason: 'manual',
+ }),
+ cancelled.signal,
+ );
+ assertActive();
+ if (result.kind === 'failed') throw new Error(`Session recap failed: ${result.errorClass}`);
+ return { ...observer.status(), recap: result.text };
+ }
let messageId: string | undefined;
let submission: MakaRunSessionResult['submission'];
let admittedQueueRevision: number | undefined;
diff --git a/packages/cli/src/run-session-result.ts b/packages/cli/src/run-session-result.ts
index 9978f9b913..cf6bc79ea1 100644
--- a/packages/cli/src/run-session-result.ts
+++ b/packages/cli/src/run-session-result.ts
@@ -28,6 +28,8 @@ export interface MakaRunSessionResult {
sessionId: string;
status: 'running' | 'needs_input' | 'needs_approval' | 'completed' | 'failed' | 'idle';
finalOutput?: string;
+ /** Model-generated snapshot of recorded progress, independent of the Turn's final output. */
+ recap?: string;
error?: string;
questions?: readonly InteractionQuestion[];
pendingQuestionCount?: number;
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org