@steerable/agent-shell 0.7.2 → 0.8.0

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,8 +37,26 @@ export interface CoreLoopToolAction {
37
37
  category?: string;
38
38
  cached?: boolean;
39
39
  waitMs?: number;
40
+ auto?: boolean;
40
41
  };
41
42
  }
43
+ /**
44
+ * What the sidecar knew about a failed model request, beyond its text.
45
+ *
46
+ * The host renders user-facing copy per `errorKind` and never parses
47
+ * `message` for it: the provider taxonomy is structured data, and message
48
+ * text is for the diagnostics panel.
49
+ */
50
+ export interface TurnFailureInfo {
51
+ /** Provider classification; `undefined` when the sidecar did not send one. */
52
+ errorKind?: string;
53
+ /** Proxy the request used, credentials stripped; `undefined` means direct. */
54
+ proxy?: string;
55
+ /** Provider attempts spent on the failing request, including the last. */
56
+ attempts?: number;
57
+ /** HTTP status when the provider answered with one. */
58
+ statusCode?: number;
59
+ }
42
60
  export interface CoreLoopTurnOutcome {
43
61
  status: string;
44
62
  reason?: string;
@@ -203,6 +221,13 @@ export interface StreamCoreLoopTurnOptions {
203
221
  }) => void;
204
222
  /** Fired when a tool finishes on the host (for tool-card rendering). */
205
223
  onToolAction?: (action: CoreLoopToolAction) => void;
224
+ /**
225
+ * Fired when the sidecar abandons the attempt it was streaming and starts
226
+ * the request again (a retry after partial output). Everything emitted via
227
+ * `onText` / `onReasoning` since the last restart belongs to a reply the
228
+ * user will never see in full and must be withdrawn from the display.
229
+ */
230
+ onRestart?: () => void;
206
231
  /** soft_timeout / budget_exhausted notices from the loop. */
207
232
  onNotice?: (kind: string, notice?: {
208
233
  kind: string;
@@ -307,6 +307,9 @@ export async function streamCoreLoopTurn(options) {
307
307
  // for the sidecar's cancelled done.
308
308
  if (cancelling)
309
309
  return;
310
+ if (chunk.restart) {
311
+ options.onRestart?.();
312
+ }
310
313
  if (chunk.delta) {
311
314
  options.onText(chunk.delta);
312
315
  }
@@ -373,8 +376,15 @@ export async function streamCoreLoopTurn(options) {
373
376
  // persist the partial trace — failed turns are the ones dogfooding
374
377
  // most needs to inspect.
375
378
  const failure = new Error(`coreloop stream failed: ${err.kind}: ${err.message}`);
379
+ const annotated = failure;
376
380
  if (err.traceId)
377
- failure.traceId = err.traceId;
381
+ annotated.traceId = err.traceId;
382
+ annotated.failureInfo = {
383
+ errorKind: err.errorKind,
384
+ proxy: err.proxy,
385
+ attempts: err.attempts,
386
+ statusCode: err.statusCode,
387
+ };
378
388
  reject(failure);
379
389
  },
380
390
  })
@@ -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,
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Which turns the `resume` channel may continue.
3
+ *
4
+ * `resume` replays the durable record's projection as the loop seed, so it can
5
+ * only continue a turn the record still ends mid-way through. Three settled
6
+ * states reach this decision, and they are not the same question:
7
+ *
8
+ * - **Interrupted** (W7-1): the process died, so the turn_active marker
9
+ * survived and no terminal assistant message was written. The user message
10
+ * is the record's tail; continuing finishes the request that never got an
11
+ * answer.
12
+ * - **Failed**: the turn's last model request exhausted its retries. The
13
+ * process is alive and settled the turn, so the marker is gone and the
14
+ * assistant message carries `completionStatus: 'failed'`. Everything
15
+ * completed before the failure is in the record — tools included — so
16
+ * continuing asks the model for the next step without repeating side
17
+ * effects.
18
+ * - **Cancelled**: the user stopped it. Continuing is allowed for the same
19
+ * reason as a failure, and deliberately: the usual cause is impatience with
20
+ * a retry, not a decision to abandon the task.
21
+ *
22
+ * A turn that completed normally is none of these. Resuming it would append a
23
+ * reply the user never asked for, which is why this is a whitelist: an
24
+ * unrecognised state refuses.
25
+ */
26
+ /** Why a turn can be continued, as far as the store can tell. */
27
+ export type ResumableReason = 'interrupted' | 'failed' | 'cancelled';
28
+ export interface ResumableTurnInput {
29
+ /** The `turn_active` marker survived: a turn began and never settled. */
30
+ turnActive: boolean;
31
+ /** A stream for this chat is live in THIS process right now. */
32
+ streamActive: boolean;
33
+ /** Role of the chat's newest persisted message, or null on an empty chat. */
34
+ lastMessageRole: string | null;
35
+ /**
36
+ * `completionStatus` of the last assistant message when the store ends on
37
+ * one. Absent when the newest message is not an assistant reply, or when the
38
+ * metadata predates the field.
39
+ */
40
+ lastAssistantStatus?: string | null;
41
+ }
42
+ /**
43
+ * Returns why the chat's last turn may be continued, or null when it may not.
44
+ *
45
+ * A live stream always refuses: the turn is running, and a second writer on
46
+ * the same record would interleave two tails.
47
+ */
48
+ export declare function resumableTurn(input: ResumableTurnInput): ResumableReason | null;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Which turns the `resume` channel may continue.
3
+ *
4
+ * `resume` replays the durable record's projection as the loop seed, so it can
5
+ * only continue a turn the record still ends mid-way through. Three settled
6
+ * states reach this decision, and they are not the same question:
7
+ *
8
+ * - **Interrupted** (W7-1): the process died, so the turn_active marker
9
+ * survived and no terminal assistant message was written. The user message
10
+ * is the record's tail; continuing finishes the request that never got an
11
+ * answer.
12
+ * - **Failed**: the turn's last model request exhausted its retries. The
13
+ * process is alive and settled the turn, so the marker is gone and the
14
+ * assistant message carries `completionStatus: 'failed'`. Everything
15
+ * completed before the failure is in the record — tools included — so
16
+ * continuing asks the model for the next step without repeating side
17
+ * effects.
18
+ * - **Cancelled**: the user stopped it. Continuing is allowed for the same
19
+ * reason as a failure, and deliberately: the usual cause is impatience with
20
+ * a retry, not a decision to abandon the task.
21
+ *
22
+ * A turn that completed normally is none of these. Resuming it would append a
23
+ * reply the user never asked for, which is why this is a whitelist: an
24
+ * unrecognised state refuses.
25
+ */
26
+ /**
27
+ * Returns why the chat's last turn may be continued, or null when it may not.
28
+ *
29
+ * A live stream always refuses: the turn is running, and a second writer on
30
+ * the same record would interleave two tails.
31
+ */
32
+ export function resumableTurn(input) {
33
+ if (input.streamActive)
34
+ return null;
35
+ if (input.turnActive) {
36
+ // A surviving marker with an assistant tail is the crash-between-write-
37
+ // and-clear window; the reply proves the turn settled, so it is not
38
+ // interrupted — but a failed reply may still be worth continuing.
39
+ return input.lastMessageRole === 'assistant'
40
+ ? statusReason(input.lastAssistantStatus)
41
+ : 'interrupted';
42
+ }
43
+ if (input.lastMessageRole !== 'assistant')
44
+ return null;
45
+ return statusReason(input.lastAssistantStatus);
46
+ }
47
+ function statusReason(status) {
48
+ if (status === 'failed')
49
+ return 'failed';
50
+ if (status === 'cancelled')
51
+ return 'cancelled';
52
+ return null;
53
+ }
@@ -1,5 +1,7 @@
1
+ import { type TurnFailureInfo } from './coreloop-stream.js';
1
2
  import type { ScopedStore } from '../storage/scoped-store.js';
2
3
  import { ToolRouter } from '../tool-router.js';
4
+ import { type ResolvedAsk } from '../sidecar/reverse-ask-user.js';
3
5
  import type { TaskService } from './task-service.js';
4
6
  import { type Principal } from '../auth/index.js';
5
7
  export interface LocalBackendRequest {
@@ -34,10 +36,14 @@ export interface WakeResult {
34
36
  status?: number;
35
37
  }
36
38
  /**
37
- * 把 CoreLoop/LLM 的底层错误翻译成适合直接显示在助手消息里的中文提示。
38
- * 原始错误仍保存在 messageMetadata.completionReason 里,便于排查。
39
+ * Reads the structured failure detail off a rejected stream.
40
+ *
41
+ * `streamCoreLoopTurn` annotates its rejection; anything else (an abort, a
42
+ * router-level error) carries none, which the renderer shows as its generic
43
+ * card. Returns undefined rather than an empty object so the persisted
44
+ * metadata omits the field entirely.
39
45
  */
40
- export declare function userFacingCoreLoopFailure(reason: string): string;
46
+ export declare function readTurnFailureInfo(err: unknown): TurnFailureInfo | undefined;
41
47
  export interface StreamOptions {
42
48
  /**
43
49
  * 取消信号。renderer 主动 Stop、窗口被销毁、应用退出时由 main 进程 abort。
@@ -46,6 +52,37 @@ export interface StreamOptions {
46
52
  */
47
53
  signal?: AbortSignal;
48
54
  }
55
+ /**
56
+ * Extracts the fields a "reconnecting" indicator needs from a retry notice.
57
+ *
58
+ * The loop announces every retry as a `hook_action`; only attempts on the
59
+ * same model request are a wait the user notices, so the round and delay are
60
+ * carried through. Returns null for any other hook action so the caller can
61
+ * keep its round-boundary handling unchanged.
62
+ */
63
+ /**
64
+ * `completionStatus` of a persisted assistant message, or null.
65
+ *
66
+ * Only the terminal assistant message's status decides whether Continue is
67
+ * offered, so this reads the field and leaves the meaning to
68
+ * `resumableTurn`. A message without metadata (older builds) yields null,
69
+ * which refuses — resuming a turn whose end state is unknown could append a
70
+ * reply to a conversation that already finished.
71
+ */
72
+ export declare function readCompletionStatus(message: {
73
+ messageMetadata?: string | null;
74
+ } | undefined): string | null;
75
+ export declare function readRetryNotice(notice: {
76
+ kind?: string;
77
+ action?: unknown;
78
+ delayMs?: unknown;
79
+ round?: unknown;
80
+ reason?: unknown;
81
+ } | undefined): {
82
+ round?: number;
83
+ delayMs?: number;
84
+ reason?: string;
85
+ } | null;
49
86
  /**
50
87
  * Out-of-band 事件推送回调。用于把"在请求生命周期之外"才出结果的事件(例如
51
88
  * 后台 AI 标题生成)异步广播到所有 renderer。main.ts 注入 `webContents.send`
@@ -60,12 +97,15 @@ export declare class LocalBackendRouter {
60
97
  private readonly broadcast;
61
98
  /** 4.6a/4.6c:任务面板的路由入口(列表 + 合并/丢弃)。未注入时任务路由 503。 */
62
99
  private readonly taskService;
100
+ private takeAskReplay;
63
101
  constructor(toolRouter: ToolRouter, options: {
64
102
  store: ScopedStore;
65
103
  resolveStore?: (principal?: Principal) => ScopedStore;
66
104
  broadcast?: LocalBackendBroadcast;
67
105
  taskService?: TaskService;
68
106
  });
107
+ /** The host records each answered ask; the following tool row stores that text. */
108
+ setAskReplay(take: (chatId: string) => ResolvedAsk | undefined): void;
69
109
  private get store();
70
110
  /** Store selected for the active request, or the host default outside one. */
71
111
  get activeStore(): ScopedStore;