@steerable/agent-shell 0.6.61 → 0.6.63

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.
@@ -0,0 +1,8 @@
1
+ import type { ImageDecoder } from './image-attachment.js';
2
+ /** True when the built-in decoder can read this path's extension. */
3
+ export declare function builtinRasterSupports(filePath: string): boolean;
4
+ /**
5
+ * Decode a local PNG or JPEG into a crop/resize/encode surface.
6
+ * Unreadable or unsupported files yield an empty image.
7
+ */
8
+ export declare function createRasterImageDecoder(): ImageDecoder;
@@ -0,0 +1,152 @@
1
+ /**
2
+ * PNG/JPEG decode, crop, resize, and re-encode for the Node host.
3
+ *
4
+ * `view_image` used to require an injected {@link ImageDecoder} (Electron
5
+ * `nativeImage`). The headless host has none, so any image larger than
6
+ * `maxEdge` failed with "当前环境不能缩放". This decoder is the default.
7
+ */
8
+ import { createRequire } from 'node:module';
9
+ import { readFileSync } from 'fs';
10
+ import { extname } from 'path';
11
+ const require = createRequire(import.meta.url);
12
+ const { PNG } = require('pngjs');
13
+ const jpeg = require('jpeg-js');
14
+ const RASTER_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg']);
15
+ /** True when the built-in decoder can read this path's extension. */
16
+ export function builtinRasterSupports(filePath) {
17
+ return RASTER_EXTENSIONS.has(extname(filePath).toLowerCase());
18
+ }
19
+ /**
20
+ * Decode a local PNG or JPEG into a crop/resize/encode surface.
21
+ * Unreadable or unsupported files yield an empty image.
22
+ */
23
+ export function createRasterImageDecoder() {
24
+ return {
25
+ createFromPath(filePath) {
26
+ const decoded = decodeRasterFile(filePath);
27
+ if (!decoded)
28
+ return EMPTY_IMAGE;
29
+ return new RasterImage(decoded.width, decoded.height, decoded.rgba);
30
+ },
31
+ };
32
+ }
33
+ function decodeRasterFile(filePath) {
34
+ if (!builtinRasterSupports(filePath))
35
+ return null;
36
+ let bytes;
37
+ try {
38
+ bytes = readFileSync(filePath);
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ try {
44
+ const ext = extname(filePath).toLowerCase();
45
+ if (ext === '.png') {
46
+ const png = PNG.sync.read(bytes);
47
+ return { width: png.width, height: png.height, rgba: Buffer.from(png.data) };
48
+ }
49
+ const decoded = jpeg.decode(bytes, { useTArray: true, formatAsRGBA: true });
50
+ if (decoded.width < 1 || decoded.height < 1)
51
+ return null;
52
+ return {
53
+ width: decoded.width,
54
+ height: decoded.height,
55
+ rgba: Buffer.from(decoded.data),
56
+ };
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ class RasterImage {
63
+ width;
64
+ height;
65
+ rgba;
66
+ constructor(width, height, rgba) {
67
+ this.width = width;
68
+ this.height = height;
69
+ this.rgba = rgba;
70
+ }
71
+ isEmpty() {
72
+ return this.width < 1 || this.height < 1 || this.rgba.length < this.width * this.height * 4;
73
+ }
74
+ getSize() {
75
+ return { width: this.width, height: this.height };
76
+ }
77
+ crop(rect) {
78
+ return new RasterImage(rect.width, rect.height, cropRgba(this.rgba, this.width, rect.x, rect.y, rect.width, rect.height));
79
+ }
80
+ resize(options) {
81
+ const width = options.width ?? this.width;
82
+ const height = options.height ?? this.height;
83
+ return new RasterImage(width, height, resizeRgba(this.rgba, this.width, this.height, width, height));
84
+ }
85
+ toPNG() {
86
+ const png = new PNG({ width: this.width, height: this.height });
87
+ this.rgba.copy(png.data);
88
+ return PNG.sync.write(png);
89
+ }
90
+ toJPEG(quality) {
91
+ const encoded = jpeg.encode({ data: this.rgba, width: this.width, height: this.height }, quality);
92
+ return Buffer.from(encoded.data);
93
+ }
94
+ }
95
+ const EMPTY_IMAGE = {
96
+ isEmpty: () => true,
97
+ getSize: () => ({ width: 0, height: 0 }),
98
+ crop: () => EMPTY_IMAGE,
99
+ resize: () => EMPTY_IMAGE,
100
+ toPNG: () => Buffer.alloc(0),
101
+ toJPEG: () => Buffer.alloc(0),
102
+ };
103
+ function cropRgba(src, srcWidth, x, y, width, height) {
104
+ const dst = Buffer.alloc(width * height * 4);
105
+ for (let row = 0; row < height; row++) {
106
+ const srcStart = ((y + row) * srcWidth + x) * 4;
107
+ src.copy(dst, row * width * 4, srcStart, srcStart + width * 4);
108
+ }
109
+ return dst;
110
+ }
111
+ /**
112
+ * Area-average downscale. Each output pixel is the mean of the source
113
+ * rectangle it covers, so a long screenshot can be sent under `maxEdge`
114
+ * without aliasing into a single row of samples.
115
+ */
116
+ function resizeRgba(src, srcWidth, srcHeight, dstWidth, dstHeight) {
117
+ if (dstWidth === srcWidth && dstHeight === srcHeight)
118
+ return Buffer.from(src);
119
+ const dst = Buffer.alloc(Math.max(0, dstWidth * dstHeight * 4));
120
+ if (dstWidth < 1 || dstHeight < 1)
121
+ return dst;
122
+ for (let y = 0; y < dstHeight; y++) {
123
+ const y0 = Math.floor((y * srcHeight) / dstHeight);
124
+ const y1 = Math.min(srcHeight, Math.max(y0 + 1, Math.ceil(((y + 1) * srcHeight) / dstHeight)));
125
+ for (let x = 0; x < dstWidth; x++) {
126
+ const x0 = Math.floor((x * srcWidth) / dstWidth);
127
+ const x1 = Math.min(srcWidth, Math.max(x0 + 1, Math.ceil(((x + 1) * srcWidth) / dstWidth)));
128
+ let r = 0;
129
+ let g = 0;
130
+ let b = 0;
131
+ let a = 0;
132
+ let count = 0;
133
+ for (let sy = y0; sy < y1; sy++) {
134
+ const row = sy * srcWidth;
135
+ for (let sx = x0; sx < x1; sx++) {
136
+ const i = (row + sx) * 4;
137
+ r += src[i];
138
+ g += src[i + 1];
139
+ b += src[i + 2];
140
+ a += src[i + 3];
141
+ count += 1;
142
+ }
143
+ }
144
+ const o = (y * dstWidth + x) * 4;
145
+ dst[o] = Math.round(r / count);
146
+ dst[o + 1] = Math.round(g / count);
147
+ dst[o + 2] = Math.round(b / count);
148
+ dst[o + 3] = Math.round(a / count);
149
+ }
150
+ }
151
+ return dst;
152
+ }
@@ -8,6 +8,7 @@ import type { ToolContribution, ToolMode, ToolExposure } from './scenario/pack.j
8
8
  export type { ToolMode, ToolExposure } from './scenario/pack.js';
9
9
  import { type AgentToolPolicy } from './local-backend/agent-capability.js';
10
10
  import { GoalStore } from './goal-store.js';
11
+ import type { LoopPtyMonitor } from './local-backend/loop-pty-monitor.js';
11
12
  /** 已注册 MCP 服务的动态工具名前缀:`mcp__<serverKey>__<toolName>`。 */
12
13
  export declare const MCP_DYNAMIC_TOOL_PREFIX = "mcp__";
13
14
  /**
@@ -144,14 +145,19 @@ export declare class ToolRouter {
144
145
  */
145
146
  private taskService;
146
147
  private worktreeService;
148
+ private loopMonitor;
147
149
  setTaskServices(services: {
148
150
  taskService: TaskService;
149
151
  worktreeService: WorktreeService;
150
152
  } | null): void;
153
+ setLoopMonitor(monitor: Pick<LoopPtyMonitor, 'start' | 'list' | 'stop'> | null): void;
154
+ listMonitoredLoops(chatId: string): import("./local-backend/loop-pty-monitor.js").MonitoredLoop[];
155
+ stopMonitoredLoop(chatId: string, loopId: string): boolean;
151
156
  /** 会话目标的持久化。缺省写到用户数据目录;测试注入临时文件。 */
152
157
  private goalStore;
153
158
  setGoalStore(store: GoalStore | null): void;
154
- private goals;
159
+ /** 目标工具使用的存储;LocalBackendRouter 的续跑与目标路由共用同一实例。 */
160
+ goals(): GoalStore;
155
161
  setWebTools(delegate: WebToolDelegate | null, names?: readonly string[]): void;
156
162
  setHostedWebSearch(handler: ((args: Record<string, unknown>) => Promise<unknown>) | null): void;
157
163
  /**
@@ -189,6 +195,7 @@ export declare class ToolRouter {
189
195
  * git 仓库。description 用英文(面向模型),与 local_* 工具同款。
190
196
  */
191
197
  private listTaskAndWorktreeSchemas;
198
+ private listLoopToolSchemas;
192
199
  /**
193
200
  * 网络读取对(W5-2):schema 与 sidecar `web_tools.py` 保持一致;description
194
201
  * 用英文(面向模型),与 local_* 工具同款。可用集由握手决定,未握手/未注册
@@ -220,8 +227,10 @@ export declare class ToolRouter {
220
227
  * 抛错(reverse-tools 总是透传;缺了就是接线 bug,响亮失败)。
221
228
  */
222
229
  private executeGoalTool;
230
+ private executeLoopTool;
223
231
  private executeJobTool;
224
232
  private requireTaskContext;
233
+ private requireChatContext;
225
234
  /**
226
235
  * Agent 写出 `…/skills/<name>/SKILL.md` 且该目录还不在已知 skill root 上时,
227
236
  * 拷进用户技能目录,Skill 设置 / `/` 菜单不必再手动导入。
@@ -9,7 +9,7 @@ import { processViewImage } from './image-attachment.js';
9
9
  import { PRESENT_FILES_SCHEMA, PRESENT_FILES_TOOL_NAME, executePresentFiles, } from './present-files.js';
10
10
  import { isHostToolCapabilityEnabled } from './host-tools.js';
11
11
  import { getResolvedHostTools } from './host-tools-runtime.js';
12
- import { GoalStore, GOAL_ACTIONS } from './goal-store.js';
12
+ import { GoalStore, MODEL_GOAL_ACTIONS } from './goal-store.js';
13
13
  import { searchGlob, searchGrep } from './workspace-search.js';
14
14
  /** 已注册 MCP 服务的动态工具名前缀:`mcp__<serverKey>__<toolName>`。 */
15
15
  export const MCP_DYNAMIC_TOOL_PREFIX = 'mcp__';
@@ -95,15 +95,28 @@ export class ToolRouter {
95
95
  */
96
96
  taskService = null;
97
97
  worktreeService = null;
98
+ loopMonitor = null;
98
99
  setTaskServices(services) {
99
100
  this.taskService = services?.taskService ?? null;
100
101
  this.worktreeService = services?.worktreeService ?? null;
101
102
  }
103
+ setLoopMonitor(monitor) {
104
+ this.loopMonitor = monitor;
105
+ }
106
+ listMonitoredLoops(chatId) {
107
+ return this.loopMonitor?.list(chatId) ?? [];
108
+ }
109
+ stopMonitoredLoop(chatId, loopId) {
110
+ if (!this.loopMonitor?.list(chatId).some((loop) => loop.id === loopId))
111
+ return false;
112
+ return this.loopMonitor.stop(loopId);
113
+ }
102
114
  /** 会话目标的持久化。缺省写到用户数据目录;测试注入临时文件。 */
103
115
  goalStore = null;
104
116
  setGoalStore(store) {
105
117
  this.goalStore = store;
106
118
  }
119
+ /** 目标工具使用的存储;LocalBackendRouter 的续跑与目标路由共用同一实例。 */
107
120
  goals() {
108
121
  if (!this.goalStore)
109
122
  this.goalStore = GoalStore.default();
@@ -433,6 +446,7 @@ export class ToolRouter {
433
446
  },
434
447
  },
435
448
  ...this.listParityToolSchemas(),
449
+ ...this.listLoopToolSchemas(),
436
450
  // ─── 4.6a/4.6b 跨 turn 后台任务 + git worktree 隔离 ─────────────
437
451
  // 服务未接线(CLI/test)时一个 schema 都不出——模型看不到不可用的工具。
438
452
  ...this.listTaskAndWorktreeSchemas(),
@@ -566,8 +580,9 @@ export class ToolRouter {
566
580
  },
567
581
  {
568
582
  name: 'create_goal',
569
- description: 'Create a persisted goal for this chat when the user\'s request is a long-running objective. '
570
- + 'Not for single-turn work. Fails if an unfinished goal already exists — update that one instead.',
583
+ description: 'Create a persisted goal for this chat. Only when the user explicitly asks to track a goal; never infer one from an ordinary task. '
584
+ + 'While the goal is active the host keeps starting follow-up turns until you mark it complete or blocked. '
585
+ + 'Fails if an unfinished goal already exists.',
571
586
  mode: 'safe_write',
572
587
  inputSchema: {
573
588
  type: 'object',
@@ -580,17 +595,17 @@ export class ToolRouter {
580
595
  },
581
596
  {
582
597
  name: 'update_goal',
583
- description: 'Change the current goal. Pass the id and revision from get_goal. '
584
- + 'action is edit (needs objective), pause, resume, complete, or blocked (needs reason). '
585
- + 'A stale revision is rejected; re-read and retry.',
598
+ description: 'Change the current goal\'s status. Pass the id and revision from get_goal; a stale revision is rejected, re-read and retry. '
599
+ + 'complete: only after checking every requirement of the objective against current evidence (files, command output, test results) and no required work remains. '
600
+ + 'blocked (needs reason): only after the same blocker has recurred for at least three consecutive goal turns and no safe next action exists; never because the work is hard or slow. '
601
+ + 'pause: only when the user explicitly asks. Resuming and editing the objective are user-only.',
586
602
  mode: 'safe_write',
587
603
  inputSchema: {
588
604
  type: 'object',
589
605
  properties: {
590
606
  id: { type: 'string' },
591
607
  revision: { type: 'integer' },
592
- action: { type: 'string', enum: [...GOAL_ACTIONS] },
593
- objective: { type: 'string', description: 'Required for action=edit.' },
608
+ action: { type: 'string', enum: [...MODEL_GOAL_ACTIONS] },
594
609
  reason: { type: 'string', description: 'Required for action=blocked.' },
595
610
  },
596
611
  required: ['id', 'revision', 'action'],
@@ -767,6 +782,51 @@ export class ToolRouter {
767
782
  },
768
783
  ];
769
784
  }
785
+ listLoopToolSchemas() {
786
+ if (!this.loopMonitor)
787
+ return [];
788
+ return [
789
+ {
790
+ name: 'loop_create',
791
+ description: 'Start recurring local work for this chat after running it once in the current turn. '
792
+ + 'Each interval wakes this chat until the loop is stopped or the host exits.',
793
+ mode: 'safe_write',
794
+ inputSchema: {
795
+ type: 'object',
796
+ properties: {
797
+ prompt: {
798
+ type: 'string',
799
+ description: 'Self-contained work to run on every wake. Include the terminal condition for finite monitoring; '
800
+ + 'omit a terminal condition for open-ended recurring work.',
801
+ },
802
+ intervalSeconds: {
803
+ type: 'integer',
804
+ description: 'Positive fixed interval in seconds.',
805
+ },
806
+ },
807
+ required: ['prompt', 'intervalSeconds'],
808
+ additionalProperties: false,
809
+ },
810
+ },
811
+ {
812
+ name: 'loop_list',
813
+ description: 'List recurring local loops owned by this chat.',
814
+ mode: 'read',
815
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
816
+ },
817
+ {
818
+ name: 'loop_stop',
819
+ description: 'Stop one recurring local loop owned by this chat.',
820
+ mode: 'safe_write',
821
+ inputSchema: {
822
+ type: 'object',
823
+ properties: { id: { type: 'string' } },
824
+ required: ['id'],
825
+ additionalProperties: false,
826
+ },
827
+ },
828
+ ];
829
+ }
770
830
  /**
771
831
  * 网络读取对(W5-2):schema 与 sidecar `web_tools.py` 保持一致;description
772
832
  * 用英文(面向模型),与 local_* 工具同款。可用集由握手决定,未握手/未注册
@@ -1054,6 +1114,10 @@ export class ToolRouter {
1054
1114
  case 'create_goal':
1055
1115
  case 'update_goal':
1056
1116
  return this.executeGoalTool(call.name, args, context);
1117
+ case 'loop_create':
1118
+ case 'loop_list':
1119
+ case 'loop_stop':
1120
+ return this.executeLoopTool(call.name, args, context);
1057
1121
  case 'job_list':
1058
1122
  case 'job_output':
1059
1123
  case 'job_kill':
@@ -1141,10 +1205,42 @@ export class ToolRouter {
1141
1205
  id: String(args.id || ''),
1142
1206
  revision: typeof args.revision === 'number' ? args.revision : Number.NaN,
1143
1207
  action: String(args.action || ''),
1144
- objective: typeof args.objective === 'string' ? args.objective : undefined,
1208
+ actor: 'model',
1145
1209
  reason: typeof args.reason === 'string' ? args.reason : undefined,
1146
1210
  });
1147
1211
  }
1212
+ executeLoopTool(name, args, context) {
1213
+ const chatId = this.requireChatContext(context, 'loop tools');
1214
+ if (!this.loopMonitor) {
1215
+ return { success: false, error: 'loop monitor unavailable', needsFollowup: true };
1216
+ }
1217
+ if (name === 'loop_list') {
1218
+ return { success: true, loops: this.loopMonitor.list(chatId) };
1219
+ }
1220
+ if (name === 'loop_stop') {
1221
+ const id = String(args.id || '');
1222
+ const owned = this.loopMonitor.list(chatId).some((loop) => loop.id === id);
1223
+ if (!owned)
1224
+ return { success: false, error: 'loop not found', needsFollowup: true };
1225
+ return { success: this.loopMonitor.stop(id) };
1226
+ }
1227
+ try {
1228
+ const loop = this.loopMonitor.start({
1229
+ chatId,
1230
+ prompt: String(args.prompt || ''),
1231
+ intervalSeconds: typeof args.intervalSeconds === 'number' ? args.intervalSeconds : Number.NaN,
1232
+ cwd: context?.projectRoot ?? undefined,
1233
+ });
1234
+ return { success: true, loop };
1235
+ }
1236
+ catch (error) {
1237
+ return {
1238
+ success: false,
1239
+ error: error instanceof Error ? error.message : String(error),
1240
+ needsFollowup: true,
1241
+ };
1242
+ }
1243
+ }
1148
1244
  async executeJobTool(name, args, context) {
1149
1245
  const chatId = this.requireTaskContext(context);
1150
1246
  const jobId = String(args.job_id || '');
@@ -1176,6 +1272,12 @@ export class ToolRouter {
1176
1272
  }
1177
1273
  return chatId;
1178
1274
  }
1275
+ requireChatContext(context, owner) {
1276
+ const chatId = context?.chatId;
1277
+ if (!chatId)
1278
+ throw new Error(`${owner} 需要 chatId 调用上下文`);
1279
+ return chatId;
1280
+ }
1179
1281
  /**
1180
1282
  * Agent 写出 `…/skills/<name>/SKILL.md` 且该目录还不在已知 skill root 上时,
1181
1283
  * 拷进用户技能目录,Skill 设置 / `/` 菜单不必再手动导入。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell",
3
- "version": "0.6.61",
3
+ "version": "0.6.63",
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/",
@@ -50,10 +50,12 @@
50
50
  "conf": "10.2.0",
51
51
  "dotenv": "^17.4.2",
52
52
  "he": "^1.2.0",
53
+ "jpeg-js": "^0.4.4",
53
54
  "node-pty": "^1.1.0",
54
- "@steerable/agent-harness": "0.6.61",
55
- "@steerable/agent-protocol": "0.6.61",
56
- "@steerable/pack-sdk": "0.6.61"
55
+ "pngjs": "^7.0.0",
56
+ "@steerable/agent-harness": "0.6.63",
57
+ "@steerable/agent-protocol": "0.6.63",
58
+ "@steerable/pack-sdk": "0.6.63"
57
59
  },
58
60
  "devDependencies": {
59
61
  "@types/better-sqlite3": "^7.6.13",