@steerable/agent-shell 0.6.56 → 0.6.58

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,27 @@
1
+ /**
2
+ * 把产品命令行包装脚本写进用户 PATH 里一个可写的目录。
3
+ * 桌面安装包里的入口是 products/<bin>/cli.js;仓库里优先用 cli.mjs。
4
+ */
5
+ export interface CliInstallResult {
6
+ path: string;
7
+ onPath: boolean;
8
+ }
9
+ export interface CliInstallLinkInput {
10
+ binName: string;
11
+ scriptPath: string;
12
+ nodePath: string;
13
+ home: string;
14
+ pathEnv: string;
15
+ platform: NodeJS.Platform;
16
+ exists(target: string): boolean;
17
+ canWrite(dir: string): boolean;
18
+ mkdir(dir: string): void;
19
+ writeFile(file: string, contents: string): void;
20
+ chmod(file: string, mode: number): void;
21
+ }
22
+ /** 在应用根下找命令行入口。 */
23
+ export declare function resolveCliScript(appRoot: string, binName: string, exists: (target: string) => boolean): string | null;
24
+ /** 写包装脚本。目录选用户主目录下、已经在 PATH 里、并且可写的那个。 */
25
+ export declare function installCliLink(input: CliInstallLinkInput): CliInstallResult;
26
+ /** 把名为 `binName` 的产品命令安装到 PATH。调用方传入命令名。 */
27
+ export declare function installProductCli(binName: string): CliInstallResult;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * 把产品命令行包装脚本写进用户 PATH 里一个可写的目录。
3
+ * 桌面安装包里的入口是 products/<bin>/cli.js;仓库里优先用 cli.mjs。
4
+ */
5
+ import { accessSync, chmodSync, constants, existsSync, mkdirSync, writeFileSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import path from 'node:path';
8
+ import { getAppRootDir } from './runtime.js';
9
+ /** 在应用根下找命令行入口。 */
10
+ export function resolveCliScript(appRoot, binName, exists) {
11
+ const candidates = [
12
+ path.join(appRoot, 'products', binName, 'cli.mjs'),
13
+ path.join(appRoot, 'products', binName, 'cli.js'),
14
+ path.join(appRoot, 'dist', 'products', binName, 'cli.js'),
15
+ ];
16
+ return candidates.find((candidate) => exists(candidate)) ?? null;
17
+ }
18
+ /** 写包装脚本。目录选用户主目录下、已经在 PATH 里、并且可写的那个。 */
19
+ export function installCliLink(input) {
20
+ if (input.binName.includes('/') || input.binName.includes('\\')) {
21
+ throw new Error(`command name "${input.binName}" cannot contain a path separator`);
22
+ }
23
+ const pathApi = input.platform === 'win32' ? path.win32 : path.posix;
24
+ const delimiter = input.platform === 'win32' ? ';' : ':';
25
+ const pathDirs = input.pathEnv.split(delimiter).filter((dir) => dir.length > 0);
26
+ const preferred = [
27
+ pathApi.join(input.home, '.local', 'bin'),
28
+ pathApi.join(input.home, 'bin'),
29
+ ];
30
+ const ordered = [];
31
+ const seen = new Set();
32
+ const push = (dir, onPath) => {
33
+ if (seen.has(dir))
34
+ return;
35
+ seen.add(dir);
36
+ ordered.push({ dir, onPath });
37
+ };
38
+ for (const dir of preferred) {
39
+ if (pathDirs.includes(dir))
40
+ push(dir, true);
41
+ }
42
+ for (const dir of pathDirs) {
43
+ if (dir === input.home || dir.startsWith(input.home + pathApi.sep))
44
+ push(dir, true);
45
+ }
46
+ push(preferred[0], pathDirs.includes(preferred[0]));
47
+ const chosen = ordered.find((entry) => input.canWrite(entry.dir));
48
+ if (!chosen) {
49
+ throw new Error('no writable directory on PATH under the home directory');
50
+ }
51
+ if (!input.exists(chosen.dir))
52
+ input.mkdir(chosen.dir);
53
+ const fileName = input.platform === 'win32' ? `${input.binName}.cmd` : input.binName;
54
+ const file = pathApi.join(chosen.dir, fileName);
55
+ const contents = input.platform === 'win32'
56
+ ? `@echo off\r\n${quoteCmd(input.nodePath)} ${quoteCmd(input.scriptPath)} %*\r\n`
57
+ : `#!/bin/sh\nexec ${quoteShell(input.nodePath)} ${quoteShell(input.scriptPath)} "$@"\n`;
58
+ input.writeFile(file, contents);
59
+ if (input.platform !== 'win32')
60
+ input.chmod(file, 0o755);
61
+ return { path: file, onPath: chosen.onPath };
62
+ }
63
+ /** 把名为 `binName` 的产品命令安装到 PATH。调用方传入命令名。 */
64
+ export function installProductCli(binName) {
65
+ const name = binName.trim();
66
+ if (!name)
67
+ throw new Error('This product has no command-line name.');
68
+ const scriptPath = resolveCliScript(getAppRootDir(), name, existsSync);
69
+ if (!scriptPath)
70
+ throw new Error('The command-line tool is not included in this installation.');
71
+ return installCliLink({
72
+ binName: name,
73
+ scriptPath,
74
+ nodePath: process.execPath,
75
+ home: homedir(),
76
+ pathEnv: process.env.PATH ?? '',
77
+ platform: process.platform,
78
+ exists: existsSync,
79
+ canWrite: canWriteDir,
80
+ mkdir: (dir) => mkdirSync(dir, { recursive: true }),
81
+ writeFile: (file, contents) => writeFileSync(file, contents, 'utf8'),
82
+ chmod: (file, mode) => chmodSync(file, mode),
83
+ });
84
+ }
85
+ function canWriteDir(dir) {
86
+ try {
87
+ if (existsSync(dir)) {
88
+ accessSync(dir, constants.W_OK);
89
+ return true;
90
+ }
91
+ accessSync(path.dirname(dir), constants.W_OK);
92
+ return true;
93
+ }
94
+ catch (error) {
95
+ // 目录或其父目录不可写。
96
+ void error;
97
+ return false;
98
+ }
99
+ }
100
+ function quoteShell(value) {
101
+ return `'${value.replaceAll("'", "'\\''")}'`;
102
+ }
103
+ function quoteCmd(value) {
104
+ return `"${value.replaceAll('"', '""')}"`;
105
+ }
@@ -4,9 +4,11 @@
4
4
  * 修订号做乐观并发:update_goal 必须带上一次读到的 revision。
5
5
  */
6
6
  import { randomUUID } from 'node:crypto';
7
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
7
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
8
8
  import path from 'node:path';
9
9
  import { getUserDataDir } from './runtime.js';
10
+ import { fileLockPath } from './storage/process-locks.js';
11
+ import { acquireWriteLease } from './storage/write-lease.js';
10
12
  export const GOAL_ACTIONS = [
11
13
  'edit',
12
14
  'pause',
@@ -87,7 +89,16 @@ export class GoalStore {
87
89
  });
88
90
  }
89
91
  queue(fn) {
90
- const run = this.pending.then(fn, fn);
92
+ const runLocked = async () => {
93
+ const lease = acquireWriteLease(fileLockPath(getUserDataDir(), 'goals'), 5_000);
94
+ try {
95
+ return await fn();
96
+ }
97
+ finally {
98
+ lease.release();
99
+ }
100
+ };
101
+ const run = this.pending.then(runLocked, runLocked);
91
102
  this.pending = run.then(() => undefined, () => undefined);
92
103
  return run;
93
104
  }
@@ -111,7 +122,9 @@ export class GoalStore {
111
122
  }
112
123
  async write(data) {
113
124
  await mkdir(path.dirname(this.filePath), { recursive: true });
114
- await writeFile(this.filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
125
+ const tmp = `${this.filePath}.${process.pid}.tmp`;
126
+ await writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
127
+ await rename(tmp, this.filePath);
115
128
  }
116
129
  }
117
130
  function applyAction(current, action, objective, reason) {
@@ -10,6 +10,14 @@ import type { HttpRouteContribution, HttpRouteRequest } from '../scenario/pack.j
10
10
  export type { HttpRouteContribution, HttpRouteRequest };
11
11
  /** 注册包 HTTP 路由;路径越出包前缀直接抛错(组装期失败,不进运行时)。 */
12
12
  export declare function registerPackHttpRoutes(packId: string, routes: readonly HttpRouteContribution[]): void;
13
+ /**
14
+ * 进程内路由器调用包的 `/host/<packId>/*` 路由。
15
+ * 处理器直接返回数据,这里包成 `{ status, data }`。
16
+ */
17
+ export declare function invokePackHttpRoute(method: string, pathname: string, body: unknown): Promise<{
18
+ status: number;
19
+ data: unknown;
20
+ } | null>;
13
21
  /** 测试钩子:清空注册表。 */
14
22
  export declare function resetPackHttpRoutes(): void;
15
23
  /**
@@ -16,9 +16,27 @@ function assertPackRoutePath(packId, path) {
16
16
  export function registerPackHttpRoutes(packId, routes) {
17
17
  for (const route of routes) {
18
18
  assertPackRoutePath(packId, route.path);
19
+ const already = packHttpRoutes.some((existing) => existing.method === route.method && existing.path === route.path);
20
+ if (already)
21
+ continue;
19
22
  packHttpRoutes.push(route);
20
23
  }
21
24
  }
25
+ /**
26
+ * 进程内路由器调用包的 `/host/<packId>/*` 路由。
27
+ * 处理器直接返回数据,这里包成 `{ status, data }`。
28
+ */
29
+ export async function invokePackHttpRoute(method, pathname, body) {
30
+ const match = matchPackHttpRoute(method, pathname);
31
+ if (!match)
32
+ return null;
33
+ const data = await match.route.handler({
34
+ params: match.params,
35
+ query: {},
36
+ body,
37
+ });
38
+ return { status: 200, data };
39
+ }
22
40
  /** 测试钩子:清空注册表。 */
23
41
  export function resetPackHttpRoutes() {
24
42
  packHttpRoutes.length = 0;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * 包命令行注册表。产品组装根在启动 CLI 前注册;agent-cli 在内置命令之后分发。
3
+ * 注册发生在 import 期或 createCli 的当次调用,不依赖宿主装配。
4
+ */
5
+ import type { CliCommandSpec } from '../scenario/pack.js';
6
+ export type { CliCommandSpec };
7
+ /** 名字必须是小写命令,且不能占用内置命令。 */
8
+ export declare function assertPackCliCommandName(name: string): void;
9
+ /** 注册一个包的命令。同包或同名重复注册抛错。 */
10
+ export declare function registerPackCliCommands(packId: string, commands: readonly CliCommandSpec[]): void;
11
+ /** 已注册命令,按注册序。 */
12
+ export declare function listPackCliCommands(): readonly CliCommandSpec[];
13
+ /**
14
+ * 合并注册表和本次调用额外传入的命令。额外命令不写入注册表,
15
+ * 所以 createCli 可以重复调用。
16
+ */
17
+ export declare function collectPackCliCommands(extra?: readonly CliCommandSpec[]): CliCommandSpec[];
18
+ /** 测试钩子。 */
19
+ export declare function resetPackCliCommands(): void;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * 包命令行注册表。产品组装根在启动 CLI 前注册;agent-cli 在内置命令之后分发。
3
+ * 注册发生在 import 期或 createCli 的当次调用,不依赖宿主装配。
4
+ */
5
+ const RESERVED = new Set(['run', 'chat', 'skills', 'mcp', 'config', 'doctor', 'tui']);
6
+ const NAME = /^[a-z][a-z0-9-]*$/;
7
+ const byPack = new Map();
8
+ /** 名字必须是小写命令,且不能占用内置命令。 */
9
+ export function assertPackCliCommandName(name) {
10
+ if (!NAME.test(name)) {
11
+ throw new Error(`[pack-cli] command name "${name}" must match ${NAME}`);
12
+ }
13
+ if (RESERVED.has(name)) {
14
+ throw new Error(`[pack-cli] command "${name}" is reserved`);
15
+ }
16
+ }
17
+ /** 注册一个包的命令。同包或同名重复注册抛错。 */
18
+ export function registerPackCliCommands(packId, commands) {
19
+ if (byPack.has(packId)) {
20
+ throw new Error(`[pack-cli] duplicate registration for pack: ${packId}`);
21
+ }
22
+ const taken = new Set(listPackCliCommands().map((command) => command.name));
23
+ for (const command of commands) {
24
+ assertPackCliCommandName(command.name);
25
+ if (taken.has(command.name)) {
26
+ throw new Error(`[pack-cli] duplicate command: ${command.name}`);
27
+ }
28
+ taken.add(command.name);
29
+ }
30
+ byPack.set(packId, commands);
31
+ }
32
+ /** 已注册命令,按注册序。 */
33
+ export function listPackCliCommands() {
34
+ return [...byPack.values()].flat();
35
+ }
36
+ /**
37
+ * 合并注册表和本次调用额外传入的命令。额外命令不写入注册表,
38
+ * 所以 createCli 可以重复调用。
39
+ */
40
+ export function collectPackCliCommands(extra = []) {
41
+ const merged = [...listPackCliCommands()];
42
+ const taken = new Set(merged.map((command) => command.name));
43
+ for (const command of extra) {
44
+ assertPackCliCommandName(command.name);
45
+ if (taken.has(command.name)) {
46
+ throw new Error(`[pack-cli] duplicate command: ${command.name}`);
47
+ }
48
+ taken.add(command.name);
49
+ merged.push(command);
50
+ }
51
+ return merged;
52
+ }
53
+ /** 测试钩子。 */
54
+ export function resetPackCliCommands() {
55
+ byPack.clear();
56
+ }
@@ -22,7 +22,9 @@ import { getProductConfig } from '../product-config.js';
22
22
  import { LocalBackendRouter } from '../local-backend/router.js';
23
23
  import { WorktreeService } from '../local-backend/worktree-service.js';
24
24
  import { TaskService } from '../local-backend/task-service.js';
25
- import { LOCAL_SCOPE, closeStorage, getPackDbAccess, getScopedStore, initializeStorage, } from '../storage/driver.js';
25
+ import { LOCAL_SCOPE, closeStorage, getPackDbAccess, getScopedStore, initializeStorage, watchStorageChanges, } from '../storage/driver.js';
26
+ import { getUserDataDir } from '../runtime.js';
27
+ import { acquireInstanceLease, bindStorageInstance, } from '../storage/process-locks.js';
26
28
  import { createApprovalBridge } from '../sidecar/reverse-approval.js';
27
29
  import { createAskUserBridge } from '../sidecar/reverse-ask-user.js';
28
30
  import { startHostSidecar, shutdownHostSidecar } from '../sidecar/boot.js';
@@ -35,10 +37,12 @@ import { recordInsightTurn } from '../insights/record.js';
35
37
  import { llmService } from '../llm/index.js';
36
38
  import { isProductLlmLocked, resolveRuntimeLlmSettings } from '../host-tools-runtime.js';
37
39
  import { getPackAssemblies, } from './pack-assembly.js';
40
+ import { registerPackHttpRoutes } from './http-routes.js';
38
41
  export async function createHostRuntime(options) {
39
42
  const { broadcast, hasWindow, onLog } = options;
40
43
  const broadcastMain = options.broadcastMain ?? broadcast;
41
44
  await initializeStorage();
45
+ watchStorageChanges(() => broadcast('store:changed', {}));
42
46
  const scope = options.scope ?? LOCAL_SCOPE;
43
47
  const defaultStore = getScopedStore(scope);
44
48
  let localBackendRouter;
@@ -154,6 +158,11 @@ export async function createHostRuntime(options) {
154
158
  packHandles.set(packId, handle);
155
159
  }
156
160
  }
161
+ for (const [packId, handle] of packHandles) {
162
+ const routes = handle.httpRoutes?.();
163
+ if (routes && routes.length > 0)
164
+ registerPackHttpRoutes(packId, routes);
165
+ }
157
166
  const worktreeService = new WorktreeService({ resolveProject: resolveChatProjectRoot });
158
167
  const taskService = new TaskService({
159
168
  store: () => localBackendRouter?.activeStore ?? defaultStore,
@@ -189,6 +198,8 @@ export async function createHostRuntime(options) {
189
198
  onLog: (line) => onLog(line),
190
199
  });
191
200
  let started = false;
201
+ let instanceLease = null;
202
+ let warmTimer = null;
192
203
  return {
193
204
  store: defaultStore,
194
205
  localExecutor,
@@ -208,8 +219,9 @@ export async function createHostRuntime(options) {
208
219
  if (started)
209
220
  return;
210
221
  started = true;
211
- // 4.6a:上次进程崩溃/强杀留下的 running 任务不是真相——任务流随进程
212
- // 一起死了,启动时落成 failed,任务面板据此显示"重启中断"。
222
+ instanceLease = acquireInstanceLease(getUserDataDir());
223
+ bindStorageInstance(instanceLease.instanceId);
224
+ // 只清扫所属进程已经退出的任务。本进程的实例锁还在,不会扫到自己。
213
225
  const sweptTasks = await defaultStore.failRunningTasks(options.taskSweepReason);
214
226
  if (sweptTasks > 0) {
215
227
  onLog(`[task] swept ${sweptTasks} stale running task(s)`);
@@ -250,7 +262,8 @@ export async function createHostRuntime(options) {
250
262
  // 后台刷新已启用 MCP 服务的工具列表(连接慢的 server 不阻塞启动)。
251
263
  void mcpServerRegistry.refreshAllEnabled();
252
264
  // 预热共享可见 PTY:首条 agent 命令不必付 shell 启动成本。
253
- setTimeout(() => {
265
+ // 短命进程在这两秒内退出时必须取消,否则定时器会在关停之后再拉起一个 shell。
266
+ warmTimer = setTimeout(() => {
254
267
  try {
255
268
  terminalManager.ensurePrimary();
256
269
  }
@@ -260,6 +273,10 @@ export async function createHostRuntime(options) {
260
273
  }, 2_000);
261
274
  },
262
275
  async shutdown() {
276
+ if (warmTimer) {
277
+ clearTimeout(warmTimer);
278
+ warmTimer = null;
279
+ }
263
280
  terminalManager.killAll();
264
281
  // 包装配逆序关停(后装配的先停)。
265
282
  for (const handle of [...packHandles.values()].reverse()) {
@@ -267,6 +284,9 @@ export async function createHostRuntime(options) {
267
284
  }
268
285
  await mcpExecutor.shutdownAll();
269
286
  await shutdownHostSidecar();
287
+ instanceLease?.release();
288
+ instanceLease = null;
289
+ bindStorageInstance(null);
270
290
  await closeStorage();
271
291
  },
272
292
  };
@@ -88,7 +88,7 @@ export async function flushInsightsOutbox(store) {
88
88
  return { uploaded: 0, skipped: 0, failed: 0 };
89
89
  }
90
90
  const settings = await store.ensureInsightsSettings();
91
- const pending = await store.listInsightOutbox({ uploaded: false, limit: MAX_BATCH });
91
+ const pending = await store.claimInsightsForUpload(MAX_BATCH);
92
92
  const { upload, skipped } = rowsEligibleForAutoUpload(settings, pending);
93
93
  let uploaded = 0;
94
94
  let failed = 0;
@@ -9,11 +9,27 @@
9
9
  */
10
10
  import ConfImport from 'conf';
11
11
  import { getUserDataDir } from './runtime.js';
12
+ import { fileLockPath } from './storage/process-locks.js';
13
+ import { acquireWriteLease } from './storage/write-lease.js';
12
14
  const Conf = ConfImport;
13
15
  export function createJsonStore(options) {
14
- return new Conf({
16
+ const store = new Conf({
15
17
  cwd: getUserDataDir(),
16
18
  name: options.name,
17
19
  defaults: options.defaults,
18
20
  });
21
+ const lockPath = fileLockPath(getUserDataDir(), options.name);
22
+ return {
23
+ get: store.get.bind(store),
24
+ // set is wrapped below so two processes cannot clobber a read-modify-write.
25
+ set(key, value) {
26
+ const lease = acquireWriteLease(lockPath, 5_000);
27
+ try {
28
+ store.set(key, value);
29
+ }
30
+ finally {
31
+ lease.release();
32
+ }
33
+ },
34
+ };
19
35
  }
@@ -169,6 +169,9 @@ export declare class LocalBackendRouter {
169
169
  */
170
170
  private briefResult;
171
171
  private briefScalar;
172
+ private chatBusy;
173
+ private tryChatLock;
174
+ private withChatWriteLock;
172
175
  private sse;
173
176
  /**
174
177
  * A4 CoreLoop turn: the sidecar runs the whole agent loop; this method