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