@steerable/agent-shell 0.7.1 → 0.7.4

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.
@@ -21,6 +21,7 @@ import { mcpExecutor } from '../mcp-executor.js';
21
21
  import { ProjectRegistry } from '../project-registry.js';
22
22
  import { getProductConfig } from '../product-config.js';
23
23
  import { LocalBackendRouter } from '../local-backend/router.js';
24
+ import { getLiveStream, recordGrantedEgress } from '../local-backend/live-stream.js';
24
25
  import { WorktreeService } from '../local-backend/worktree-service.js';
25
26
  import { TaskService } from '../local-backend/task-service.js';
26
27
  import { LoopPtyMonitor } from '../local-backend/loop-pty-monitor.js';
@@ -224,6 +225,27 @@ export async function createHostRuntime(options) {
224
225
  ...(declaredLabel ? { declaredLabel } : {}),
225
226
  });
226
227
  },
228
+ recordEgressGrant: (prompt, decision) => {
229
+ if (!prompt.chatId) {
230
+ onLog('approval: egress grant has no chatId; not stored');
231
+ return;
232
+ }
233
+ const live = getLiveStream(prompt.chatId);
234
+ if (!live || live.status !== 'running')
235
+ return;
236
+ recordGrantedEgress(live, {
237
+ requestId: prompt.requestId,
238
+ arguments: prompt.arguments,
239
+ kind: decision.kind,
240
+ reason: decision.reason,
241
+ });
242
+ },
243
+ blockedCommand: (toolName, args) => {
244
+ if (toolName !== 'local_exec_shell')
245
+ return null;
246
+ const command = typeof args.command === 'string' ? args.command : '';
247
+ return localExecutor.detectDangerousCommand(command);
248
+ },
227
249
  outsideWorkspace: async ({ toolName, arguments: args, chatId }) => {
228
250
  if (!chatId || !localBackendRouter)
229
251
  return false;
@@ -246,6 +268,7 @@ export async function createHostRuntime(options) {
246
268
  hasWindow,
247
269
  onLog: (line) => onLog(line),
248
270
  });
271
+ localBackendRouter.setAskReplay((chatId) => askUserBridge.takeResolved(chatId));
249
272
  let started = false;
250
273
  let instanceLease = null;
251
274
  let warmTimer = null;
@@ -293,7 +316,7 @@ export async function createHostRuntime(options) {
293
316
  ...(project?.sourceFolders ?? []),
294
317
  ...collectPackExecWritableRoots(chatId),
295
318
  // 技能根:workflows/references/templates 是只读参考,模型必须能读
296
- // (spec_lock_reference.md 定义 pptx_layouts 怎么写,正是它被拒过)。
319
+ // (技能包里的版式说明曾因读不到而被拒)。
297
320
  ...listSkillRoots(),
298
321
  // spill 根:工具输出过大时 sidecar 落到这里,模型要读回全文。
299
322
  ...spillReadRoots(),
@@ -37,6 +37,7 @@ export interface CoreLoopToolAction {
37
37
  category?: string;
38
38
  cached?: boolean;
39
39
  waitMs?: number;
40
+ auto?: boolean;
40
41
  };
41
42
  }
42
43
  export interface CoreLoopTurnOutcome {
@@ -12,7 +12,7 @@
12
12
  * 活数组引用(appendTimelineDelta / syncTimelineTools / onToolAction 都
13
13
  * 原地 mutate),因此 GET 序列化时自然读到最新状态,无需逐事件拷贝。
14
14
  */
15
- import type { PersistedTurnBlock } from './turn-timeline.js';
15
+ import { type PersistedTurnBlock } from './turn-timeline.js';
16
16
  export interface LiveStreamState {
17
17
  chatId: string;
18
18
  status: 'running' | 'completed' | 'cancelled' | 'failed';
@@ -24,7 +24,16 @@ export interface LiveStreamState {
24
24
  timeline: PersistedTurnBlock[];
25
25
  /** 编排子代理生命周期事件(按到达顺序累积)。 */
26
26
  children: Array<Record<string, unknown>>;
27
+ /** 出网放行写入 executedActions 后通知正在看这一轮的界面。 */
28
+ notify?: () => void;
27
29
  }
30
+ /** 把一次已允许的出网放行记进正在运行的回合。这不是工具结果,模型看不到。 */
31
+ export declare function recordGrantedEgress(live: LiveStreamState, grant: {
32
+ requestId: string;
33
+ arguments: Record<string, unknown>;
34
+ kind: string;
35
+ reason: string;
36
+ }): void;
28
37
  export declare function registerLiveStream(chatId: string, refs: {
29
38
  executedActions: Array<Record<string, unknown>>;
30
39
  timeline: PersistedTurnBlock[];
@@ -1,4 +1,34 @@
1
+ /**
2
+ * 运行中回合的「实时快照」注册表 —— 让 renderer 在切走再切回时能把正在
3
+ * 流式的回合重新接上(显示运行状态 + 已产出的部分内容 / 工具卡片 / 时间线)。
4
+ *
5
+ * 背景:AgentChatView 卸载时不再取消回合(回合不因切页而暂停),但该回合
6
+ * 的 SSE 事件只会流向当初发起 fetch 的那个组件;切回后的新组件拿不到这些
7
+ * 事件,只能靠轮询本注册表把「运行中 + 部分产出」恢复出来。
8
+ *
9
+ * 设计:注册表只存「正在运行」的回合。router 的 handleCoreLoopTurn 在
10
+ * 流开始前 register、每个产出点更新 content、结束(落库后)立即 remove。
11
+ * executedActions / timeline / children 三个字段存的是 router 里同名的
12
+ * 活数组引用(appendTimelineDelta / syncTimelineTools / onToolAction 都
13
+ * 原地 mutate),因此 GET 序列化时自然读到最新状态,无需逐事件拷贝。
14
+ */
15
+ import { syncTimelineTools } from './turn-timeline.js';
1
16
  const liveStreams = new Map();
17
+ /** 把一次已允许的出网放行记进正在运行的回合。这不是工具结果,模型看不到。 */
18
+ export function recordGrantedEgress(live, grant) {
19
+ live.executedActions.push({
20
+ id: `egress-${grant.requestId}`,
21
+ tool: 'network_egress',
22
+ arguments: grant.arguments,
23
+ success: true,
24
+ approval: {
25
+ kind: grant.kind,
26
+ ...(grant.reason === 'unattended' ? { auto: true } : {}),
27
+ },
28
+ });
29
+ syncTimelineTools(live.timeline, live.executedActions);
30
+ live.notify?.();
31
+ }
2
32
  export function registerLiveStream(chatId, refs) {
3
33
  const state = {
4
34
  chatId,
@@ -1,5 +1,6 @@
1
1
  import type { ScopedStore } from '../storage/scoped-store.js';
2
2
  import { ToolRouter } from '../tool-router.js';
3
+ import { type ResolvedAsk } from '../sidecar/reverse-ask-user.js';
3
4
  import type { TaskService } from './task-service.js';
4
5
  import { type Principal } from '../auth/index.js';
5
6
  export interface LocalBackendRequest {
@@ -60,12 +61,15 @@ export declare class LocalBackendRouter {
60
61
  private readonly broadcast;
61
62
  /** 4.6a/4.6c:任务面板的路由入口(列表 + 合并/丢弃)。未注入时任务路由 503。 */
62
63
  private readonly taskService;
64
+ private takeAskReplay;
63
65
  constructor(toolRouter: ToolRouter, options: {
64
66
  store: ScopedStore;
65
67
  resolveStore?: (principal?: Principal) => ScopedStore;
66
68
  broadcast?: LocalBackendBroadcast;
67
69
  taskService?: TaskService;
68
70
  });
71
+ /** The host records each answered ask; the following tool row stores that text. */
72
+ setAskReplay(take: (chatId: string) => ResolvedAsk | undefined): void;
69
73
  private get store();
70
74
  /** Store selected for the active request, or the host default outside one. */
71
75
  get activeStore(): ScopedStore;
@@ -27,6 +27,7 @@ import { sanitizeCompatOverrides, sanitizePresetsChoice, sanitizeLlmProvider, sa
27
27
  import { setDefaultExecTimeoutMs } from '../local-executor.js';
28
28
  import { allowEgressForBaseUrl, getActiveEgressBroker, getEgressPosture } from '../sidecar/egress-proxy.js';
29
29
  import { buildExecSandbox, parseExecPolicy } from '../sidecar/exec-sandbox.js';
30
+ import { storedAskResult } from '../sidecar/reverse-ask-user.js';
30
31
  import { clampExecPolicy } from '../host-tools.js';
31
32
  import { getResolvedHostTools, isProductLlmLocked, resolveRuntimeLlmSettings, resolveTurnApproval, resolveTurnChatMode, resolveTurnOrchestration, } from '../host-tools-runtime.js';
32
33
  import { SidecarSupervisor } from '../sidecar/index.js';
@@ -320,6 +321,7 @@ export class LocalBackendRouter {
320
321
  broadcast;
321
322
  /** 4.6a/4.6c:任务面板的路由入口(列表 + 合并/丢弃)。未注入时任务路由 503。 */
322
323
  taskService;
324
+ takeAskReplay = null;
323
325
  constructor(toolRouter, options) {
324
326
  this.toolRouter = toolRouter;
325
327
  this.defaultStore = options.store;
@@ -331,6 +333,10 @@ export class LocalBackendRouter {
331
333
  this.broadcast?.('goal-changed', { chatId, goal });
332
334
  });
333
335
  }
336
+ /** The host records each answered ask; the following tool row stores that text. */
337
+ setAskReplay(take) {
338
+ this.takeAskReplay = take;
339
+ }
334
340
  get store() {
335
341
  return this.storeContext.getStore() ?? this.defaultStore;
336
342
  }
@@ -3674,6 +3680,9 @@ export class LocalBackendRouter {
3674
3680
  // 实时快照:executedActions / timeline / children 传引用(原地 mutate),
3675
3681
  // content 由 onText 逐段更新。切走再切回的 renderer 靠 GET /live-stream 读它。
3676
3682
  const live = registerLiveStream(chatId, { executedActions, timeline, children });
3683
+ live.notify = () => {
3684
+ emit(this.sseData({ type: 'executed_actions', actions: [...executedActions] }));
3685
+ };
3677
3686
  // 场景包的回合观察器(如识别包技能调用并广播产物更新)。
3678
3687
  const packObservers = beginPackTurnObservers({ chatId, broadcast: this.broadcast ?? undefined });
3679
3688
  // 回合产物文件列表(回合收尾时收集):扫描根与 exec 沙箱可写根同源
@@ -3817,13 +3826,15 @@ export class LocalBackendRouter {
3817
3826
  const idx = action.id
3818
3827
  ? executedActions.findIndex((row) => row.id === action.id)
3819
3828
  : -1;
3829
+ const replay = action.tool === 'ask_user' ? this.takeAskReplay?.(args.chatId) : undefined;
3830
+ const result = replay ? storedAskResult(replay) : action.result;
3820
3831
  const row = {
3821
3832
  id: action.id,
3822
3833
  tool: action.tool,
3823
3834
  mode: chatMode,
3824
3835
  arguments: action.arguments,
3825
- view: presentToolCall(action.tool, action.arguments, action.result),
3826
- result: action.result,
3836
+ view: presentToolCall(action.tool, action.arguments, result),
3837
+ result,
3827
3838
  success: action.success,
3828
3839
  error: action.error,
3829
3840
  durationMs: action.durationMs,
@@ -5,9 +5,11 @@
5
5
  *
6
6
  * Fail-closed by construction: no window, no listener, a renderer error,
7
7
  * or an invalid reply all become `deny_once` — never a hang, never an
8
- * auto-allow. An explicit `timeoutMs` on the approval config still fails
9
- * closed as `timed_out`; the desktop omits it, so the prompt waits until
10
- * the user decides.
8
+ * auto-allow. A command on the dangerous-command list is the exception:
9
+ * the bridge returns `allow_once` without a card so the executor can
10
+ * reject it with the blocked-command error. An explicit `timeoutMs` on
11
+ * the approval config still fails closed as `timed_out`; the desktop
12
+ * omits it, so the prompt waits until the user decides.
11
13
  *
12
14
  * The renderer answers via the `approval:decide` invoke with the requestId
13
15
  * echoed back; decisions are validated against the algebra's kind set
@@ -68,6 +70,19 @@ export interface ApprovalBridgeDeps {
68
70
  arguments: Record<string, unknown>;
69
71
  chatId?: string;
70
72
  }) => boolean | 'project' | 'workspace' | Promise<boolean | 'project' | 'workspace'>;
73
+ /**
74
+ * Pattern that will block this command in the executor.
75
+ * When set, the bridge does not show a card.
76
+ */
77
+ blockedCommand?: (toolName: string, args: Record<string, unknown>) => string | null;
78
+ /**
79
+ * An allowed network_egress grant is not a tool result. The host stores it
80
+ * on the running turn so the row survives reopening the chat.
81
+ */
82
+ recordEgressGrant?: (prompt: ApprovalPromptRequest, decision: {
83
+ kind: ApprovalDecisionKind;
84
+ reason: string;
85
+ }) => void;
71
86
  }
72
87
  export interface ApprovalBridge {
73
88
  /** Reverse-channel handler for the sidecar's `approval.request`. */
@@ -5,9 +5,11 @@
5
5
  *
6
6
  * Fail-closed by construction: no window, no listener, a renderer error,
7
7
  * or an invalid reply all become `deny_once` — never a hang, never an
8
- * auto-allow. An explicit `timeoutMs` on the approval config still fails
9
- * closed as `timed_out`; the desktop omits it, so the prompt waits until
10
- * the user decides.
8
+ * auto-allow. A command on the dangerous-command list is the exception:
9
+ * the bridge returns `allow_once` without a card so the executor can
10
+ * reject it with the blocked-command error. An explicit `timeoutMs` on
11
+ * the approval config still fails closed as `timed_out`; the desktop
12
+ * omits it, so the prompt waits until the user decides.
11
13
  *
12
14
  * The renderer answers via the `approval:decide` invoke with the requestId
13
15
  * echoed back; decisions are validated against the algebra's kind set
@@ -38,15 +40,19 @@ export function createApprovalBridge(deps) {
38
40
  if (!toolName) {
39
41
  return { kind: 'deny_once', reason: 'malformed approval request (no toolName)' };
40
42
  }
43
+ const args = p.arguments && typeof p.arguments === 'object'
44
+ ? p.arguments
45
+ : {};
46
+ if (deps.blockedCommand?.(toolName, args)) {
47
+ deps.onLog?.(`approval: ${toolName} matches the dangerous-command list; no card`);
48
+ return { kind: 'allow_once', reason: '' };
49
+ }
41
50
  if (!deps.hasWindow()) {
42
51
  deps.onLog?.(`approval: no renderer window; denying ${toolName} once`);
43
52
  return deny('no renderer window available to approve the call');
44
53
  }
45
54
  const requestId = randomUUID();
46
55
  const chatId = typeof p.chatId === 'string' && p.chatId ? p.chatId : undefined;
47
- const args = p.arguments && typeof p.arguments === 'object'
48
- ? p.arguments
49
- : {};
50
56
  const category = typeof p.category === 'string' ? p.category : toolName;
51
57
  const categoryLabel = deps.categoryLabel?.(toolName, category);
52
58
  const fence = deps.outsideWorkspace
@@ -91,10 +97,17 @@ export function createApprovalBridge(deps) {
91
97
  entry.resolve(deny(`renderer returned an invalid decision kind: ${String(p.kind)}`));
92
98
  return { ok: true };
93
99
  }
94
- entry.resolve({
95
- kind,
96
- reason: typeof p.reason === 'string' ? p.reason : '',
97
- });
100
+ const reason = typeof p.reason === 'string' ? p.reason : '';
101
+ entry.resolve({ kind, reason });
102
+ if (entry.prompt.toolName === 'network_egress'
103
+ && (kind === 'allow_once' || kind === 'allow_for_session' || kind === 'allow_always')) {
104
+ try {
105
+ deps.recordEgressGrant?.(entry.prompt, { kind, reason });
106
+ }
107
+ catch (err) {
108
+ deps.onLog?.(`approval: failed to store egress grant: ${err instanceof Error ? err.message : String(err)}`);
109
+ }
110
+ }
98
111
  return { ok: true };
99
112
  },
100
113
  };
@@ -19,6 +19,13 @@ export interface AskUserPromptRequest {
19
19
  /** 发起提问的对话。renderer 只在这条会话的输入框展示卡片。 */
20
20
  chatId?: string;
21
21
  }
22
+ /** One answered ask, kept so the tool row can store the full text. */
23
+ export interface ResolvedAsk {
24
+ intro: string;
25
+ questions: Array<Record<string, unknown>>;
26
+ answers: Record<string, string | string[]>;
27
+ unattended?: boolean;
28
+ }
22
29
  export interface AskUserBridgeDeps {
23
30
  broadcast: (channel: 'ask-user:request', payload: AskUserPromptRequest) => void;
24
31
  /** 是否有能应答的窗口/浏览器连接;没有则立即空答。 */
@@ -34,5 +41,17 @@ export interface AskUserBridge {
34
41
  answer: (payload: unknown) => {
35
42
  ok: boolean;
36
43
  };
44
+ /**
45
+ * The next answered ask for this chat. The tool result that follows only
46
+ * carries a short preview, so the turn stores this instead.
47
+ */
48
+ takeResolved: (chatId: string) => ResolvedAsk | undefined;
37
49
  }
50
+ /** The tool-row result stored in place of the truncated preview. */
51
+ export declare function storedAskResult(ask: ResolvedAsk): {
52
+ intro: string;
53
+ questions: Array<Record<string, unknown>>;
54
+ answers: Record<string, string | string[]>;
55
+ unattended?: true;
56
+ };
38
57
  export declare function createAskUserBridge(deps: AskUserBridgeDeps): AskUserBridge;
@@ -11,25 +11,59 @@
11
11
  */
12
12
  import { randomUUID } from 'node:crypto';
13
13
  const EMPTY_REPLY = { answers: {} };
14
+ function rememberAsk(resolved, chatId, intro, questions, answers, unattended) {
15
+ if (questions.length === 0)
16
+ return;
17
+ const key = chatId ?? '';
18
+ const queue = resolved.get(key) ?? [];
19
+ queue.push({
20
+ intro,
21
+ questions,
22
+ answers,
23
+ ...(unattended ? { unattended: true } : {}),
24
+ });
25
+ resolved.set(key, queue);
26
+ }
27
+ function shiftAsk(resolved, key) {
28
+ const queue = resolved.get(key);
29
+ if (!queue || queue.length === 0)
30
+ return undefined;
31
+ const next = queue.shift();
32
+ if (queue.length === 0)
33
+ resolved.delete(key);
34
+ return next;
35
+ }
36
+ /** The tool-row result stored in place of the truncated preview. */
37
+ export function storedAskResult(ask) {
38
+ return {
39
+ intro: ask.intro,
40
+ questions: ask.questions,
41
+ answers: ask.answers,
42
+ ...(ask.unattended ? { unattended: true } : {}),
43
+ };
44
+ }
14
45
  export function createAskUserBridge(deps) {
15
46
  const pending = new Map();
47
+ const resolved = new Map();
16
48
  return {
17
49
  handler: (params) => {
18
50
  const p = (params ?? {});
19
51
  const questions = Array.isArray(p.questions) ? p.questions : [];
20
52
  const chatId = typeof p.chatId === 'string' && p.chatId ? p.chatId : undefined;
53
+ const intro = typeof p.intro === 'string' ? p.intro : '';
21
54
  if (questions.length === 0) {
22
55
  deps.onLog?.('ask_user: malformed request (no questions); answering empty');
23
56
  return Promise.resolve({ ...EMPTY_REPLY });
24
57
  }
25
58
  if (!deps.hasWindow()) {
26
59
  deps.onLog?.('ask_user: no renderer window; answering empty');
60
+ rememberAsk(resolved, chatId, intro, questions, {}, false);
27
61
  return Promise.resolve({ ...EMPTY_REPLY });
28
62
  }
29
63
  const requestId = randomUUID();
30
64
  const prompt = {
31
65
  requestId,
32
- intro: typeof p.intro === 'string' ? p.intro : '',
66
+ intro,
33
67
  questions: questions,
34
68
  ...(chatId ? { chatId } : {}),
35
69
  };
@@ -41,6 +75,7 @@ export function createAskUserBridge(deps) {
41
75
  catch (err) {
42
76
  pending.delete(requestId);
43
77
  deps.onLog?.(`ask_user: broadcast failed: ${String(err)}`);
78
+ rememberAsk(resolved, chatId, intro, prompt.questions, {}, false);
44
79
  resolve({ ...EMPTY_REPLY });
45
80
  }
46
81
  });
@@ -55,8 +90,10 @@ export function createAskUserBridge(deps) {
55
90
  const answers = p.answers && typeof p.answers === 'object' && !Array.isArray(p.answers)
56
91
  ? p.answers
57
92
  : {};
93
+ rememberAsk(resolved, entry.prompt.chatId, entry.prompt.intro, entry.prompt.questions, answers, p.unattended === true);
58
94
  entry.resolve({ answers });
59
95
  return { ok: true };
60
96
  },
97
+ takeResolved: (chatId) => shiftAsk(resolved, chatId) ?? (chatId ? shiftAsk(resolved, '') : undefined),
61
98
  };
62
99
  }
@@ -531,12 +531,13 @@ export interface SidecarStreamChunk {
531
531
  backend?: string;
532
532
  enforcement: string;
533
533
  };
534
- /** Session-allow reuse. `cached` means this call did not show a card. */
534
+ /** Session-allow reuse. `cached` means this call did not show a card. `auto` is an unattended allow. */
535
535
  approval?: {
536
536
  kind?: string;
537
537
  category?: string;
538
538
  cached?: boolean;
539
539
  waitMs?: number;
540
+ auto?: boolean;
540
541
  };
541
542
  };
542
543
  /** CoreLoop notices: soft_timeout / budget_exhausted / round_end / hook_action. */
@@ -38,6 +38,24 @@ function dataOf(result) {
38
38
  function failed(result) {
39
39
  return Boolean(result && typeof result === 'object' && result.success === false);
40
40
  }
41
+ function askTitle(args, result) {
42
+ return clip(firstQuestion(args) || firstQuestion(result) || str(args, 'intro') || str(args, 'question')) || '提问';
43
+ }
44
+ function firstQuestion(value) {
45
+ if (!value || typeof value !== 'object' || Array.isArray(value))
46
+ return '';
47
+ const questions = value.questions;
48
+ if (!Array.isArray(questions))
49
+ return '';
50
+ for (const question of questions) {
51
+ if (!question || typeof question !== 'object' || Array.isArray(question))
52
+ continue;
53
+ const text = str(question, 'text').trim();
54
+ if (text)
55
+ return text;
56
+ }
57
+ return '';
58
+ }
41
59
  function commandTitle(args) {
42
60
  return clip(str(args, 'command')) || 'shell';
43
61
  }
@@ -230,7 +248,7 @@ const PRESENTERS = {
230
248
  ask_user: {
231
249
  card: 'generic',
232
250
  kind: 'other',
233
- title: (args) => clip(str(args, 'intro') || str(args, 'question')) || '提问',
251
+ title: (args, result) => askTitle(args, result),
234
252
  },
235
253
  skill: { card: 'generic', kind: 'read', title: (args) => str(args, 'name') || 'skill' },
236
254
  delegate_subagent: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell",
3
- "version": "0.7.1",
3
+ "version": "0.7.4",
4
4
  "description": "Steerable framework — product-neutral Node HostRuntime and headless HTTP server shared by Tauri desktop and browser deployments; scenario packs extend it through @steerable/pack-sdk.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://steerableframework.com/",
@@ -53,9 +53,9 @@
53
53
  "jpeg-js": "^0.4.4",
54
54
  "node-pty": "^1.1.0",
55
55
  "pngjs": "^7.0.0",
56
- "@steerable/agent-harness": "0.7.1",
57
- "@steerable/pack-sdk": "0.7.1",
58
- "@steerable/agent-protocol": "0.7.1"
56
+ "@steerable/agent-harness": "0.7.4",
57
+ "@steerable/agent-protocol": "0.7.4",
58
+ "@steerable/pack-sdk": "0.7.4"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/better-sqlite3": "^7.6.13",