@steerable/agent-shell 0.6.20 → 0.6.22
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.
- package/README.md +34 -0
- package/dist/auth/index.d.ts +38 -0
- package/dist/auth/index.js +20 -0
- package/dist/host/pack-assembly.d.ts +7 -7
- package/dist/host/runtime.d.ts +6 -2
- package/dist/host/runtime.js +54 -15
- package/dist/host/visible-terminal-exec.d.ts +0 -2
- package/dist/host/visible-terminal-exec.js +4 -8
- package/dist/insights/flush.d.ts +5 -4
- package/dist/insights/flush.js +14 -15
- package/dist/insights/record.d.ts +6 -5
- package/dist/insights/record.js +9 -10
- package/dist/llm/index.d.ts +6 -2
- package/dist/llm/index.js +19 -9
- package/dist/local-backend/ai-suggestions.d.ts +32 -0
- package/dist/local-backend/ai-suggestions.js +160 -0
- package/dist/local-backend/coreloop-stream.js +1 -1
- package/dist/local-backend/mentioned-paths.d.ts +28 -0
- package/dist/local-backend/mentioned-paths.js +67 -0
- package/dist/local-backend/prompt-builder.d.ts +10 -4
- package/dist/local-backend/prompt-builder.js +13 -12
- package/dist/local-backend/router.d.ts +26 -4
- package/dist/local-backend/router.js +243 -96
- package/dist/local-backend/skills/00-identity/SKILL.md +1 -1
- package/dist/local-backend/task-service.d.ts +14 -13
- package/dist/local-backend/task-service.js +32 -29
- package/dist/local-backend/turn-files.d.ts +57 -0
- package/dist/local-backend/turn-files.js +285 -0
- package/dist/local-backend/worktree-service.d.ts +1 -1
- package/dist/local-backend/worktree-service.js +6 -6
- package/dist/main.js +45 -26
- package/dist/preload.cjs +30 -10
- package/dist/preload.js +33 -10
- package/dist/server/http-server.d.ts +13 -1
- package/dist/server/http-server.js +45 -8
- package/dist/server/index.js +4 -90
- package/dist/server/sse-bus.d.ts +1 -1
- package/dist/server/start.d.ts +26 -0
- package/dist/server/start.js +111 -0
- package/dist/sidecar/boot.d.ts +3 -1
- package/dist/sidecar/boot.js +6 -7
- package/dist/sidecar/reverse-approval.d.ts +2 -0
- package/dist/sidecar/reverse-approval.js +2 -1
- package/dist/sidecar/reverse-ask-user.d.ts +2 -0
- package/dist/sidecar/reverse-ask-user.js +2 -1
- package/dist/sidecar/reverse-spawn.js +12 -4
- package/dist/sidecar/reverse-tools.d.ts +1 -1
- package/dist/sidecar/reverse-tools.js +1 -1
- package/dist/sidecar/storage-path.js +2 -3
- package/dist/storage/driver.d.ts +57 -0
- package/dist/storage/driver.js +104 -0
- package/dist/storage/index.d.ts +80 -67
- package/dist/storage/index.js +528 -296
- package/dist/storage/pack-migrations.d.ts +2 -4
- package/dist/storage/pack-migrations.js +2 -4
- package/dist/storage/scoped-store.d.ts +125 -0
- package/dist/storage/scoped-store.js +1 -0
- package/dist/storage/sqlite-driver.d.ts +16 -0
- package/dist/storage/sqlite-driver.js +135 -0
- package/dist/storage/write-lease-error.d.ts +11 -0
- package/dist/storage/write-lease-error.js +18 -0
- package/package.json +14 -5
- package/dist/storage/local-store-singleton.d.ts +0 -28
- package/dist/storage/local-store-singleton.js +0 -38
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: identity
|
|
3
|
-
description: Defines the
|
|
3
|
+
description: Defines the core role, working environment, and language convention. Always loaded as the foundational skill. {agentName} is the current chat agent display name (product brand if none).
|
|
4
4
|
priority: 1000
|
|
5
5
|
tags: [identity, base]
|
|
6
6
|
---
|
|
@@ -29,7 +29,7 @@ import type { ToolRouter } from '../tool-router.js';
|
|
|
29
29
|
import { streamCoreLoopTurn } from './coreloop-stream.js';
|
|
30
30
|
import type { WorktreeService } from './worktree-service.js';
|
|
31
31
|
import { type PersistedTurnBlock } from './turn-timeline.js';
|
|
32
|
-
/**
|
|
32
|
+
/** 任务表的最小异步读写面(生产注入 scoped store;测试注入内存实现)。 */
|
|
33
33
|
export interface TaskStore {
|
|
34
34
|
createTask(input: {
|
|
35
35
|
chatId: string;
|
|
@@ -39,22 +39,22 @@ export interface TaskStore {
|
|
|
39
39
|
recordId?: string | null;
|
|
40
40
|
dependsOn?: string[] | null;
|
|
41
41
|
initialStatus?: 'blocked' | 'running';
|
|
42
|
-
}): TaskRecord
|
|
43
|
-
getTask(taskId: string): TaskRecord | null
|
|
44
|
-
listTasks(chatId?: string, limit?: number): TaskRecord[]
|
|
45
|
-
updateTask(taskId: string, updates: Partial<Pick<TaskRecord, 'status' | 'answer' | 'error' | 'worktreeState' | 'traceId' | 'recordId'>>): TaskRecord | null
|
|
42
|
+
}): Promise<TaskRecord>;
|
|
43
|
+
getTask(taskId: string): Promise<TaskRecord | null>;
|
|
44
|
+
listTasks(chatId?: string, limit?: number): Promise<TaskRecord[]>;
|
|
45
|
+
updateTask(taskId: string, updates: Partial<Pick<TaskRecord, 'status' | 'answer' | 'error' | 'worktreeState' | 'traceId' | 'recordId'>>): Promise<TaskRecord | null>;
|
|
46
46
|
/** 回写推理时间线;缺省实现可为空(测试假 store 内存覆盖)。 */
|
|
47
|
-
saveTaskProcess?(taskId: string, processJson: string): void
|
|
47
|
+
saveTaskProcess?(taskId: string, processJson: string): Promise<void>;
|
|
48
48
|
}
|
|
49
49
|
export interface TaskServiceDeps {
|
|
50
|
-
store: TaskStore;
|
|
50
|
+
store: TaskStore | (() => TaskStore);
|
|
51
51
|
toolRouter: ToolRouter;
|
|
52
52
|
worktreeService: WorktreeService;
|
|
53
53
|
/** chatId → 绑定项目(无项目对话返回 null)。与 router.resolveChatProject 同源。 */
|
|
54
|
-
resolveChatProject: (chatId: string) => {
|
|
54
|
+
resolveChatProject: (chatId: string) => Promise<{
|
|
55
55
|
name: string;
|
|
56
56
|
folderPath: string;
|
|
57
|
-
} | null
|
|
57
|
+
} | null>;
|
|
58
58
|
/** 任务终态 / 推理过程广播(renderer 任务面板与右侧过程栏)。 */
|
|
59
59
|
broadcast?: (eventName: 'task-updated' | 'task-process', payload: Record<string, unknown>) => void;
|
|
60
60
|
/** 测试缝:替换真实流驱动(默认 streamCoreLoopTurn)。 */
|
|
@@ -68,6 +68,7 @@ export declare class TaskService {
|
|
|
68
68
|
private readonly deps;
|
|
69
69
|
private readonly running;
|
|
70
70
|
constructor(deps: TaskServiceDeps);
|
|
71
|
+
private get store();
|
|
71
72
|
/**
|
|
72
73
|
* task_run 工具的实现:落任务表 + 点火独立流,立即返回。
|
|
73
74
|
* 任何同步可知的失败(无 sidecar、worktree 建不起来)都抛给工具层,
|
|
@@ -113,21 +114,21 @@ export declare class TaskService {
|
|
|
113
114
|
private cancelProcessFlush;
|
|
114
115
|
private executeTask;
|
|
115
116
|
/** task_status 工具:单任务或本 chat 全部任务的快照。 */
|
|
116
|
-
status(chatId: string, taskId?: string): Record<string, unknown
|
|
117
|
+
status(chatId: string, taskId?: string): Promise<Record<string, unknown>>;
|
|
117
118
|
/** task_result 工具:取终态任务的完整结果。 */
|
|
118
|
-
result(chatId: string, taskId: string): Record<string, unknown
|
|
119
|
+
result(chatId: string, taskId: string): Promise<Record<string, unknown>>;
|
|
119
120
|
/** 喂给模型的任务视图(完整字段,answer 截断防爆上下文)。 */
|
|
120
121
|
private toModelView;
|
|
121
122
|
/**
|
|
122
123
|
* 右侧过程栏:当前推理时间线。running 且进程内有流 = live;
|
|
123
124
|
* 表上 running 但本进程没流 = 上次崩溃残留(stale)。
|
|
124
125
|
*/
|
|
125
|
-
getProcess(taskId: string): {
|
|
126
|
+
getProcess(taskId: string): Promise<{
|
|
126
127
|
task: TaskRecord;
|
|
127
128
|
timeline: PersistedTurnBlock[];
|
|
128
129
|
live: boolean;
|
|
129
130
|
stale: boolean;
|
|
130
|
-
} | null
|
|
131
|
+
} | null>;
|
|
131
132
|
/** 合并任务 worktree 到主仓(UI「合并到主仓」按钮的路由实现)。 */
|
|
132
133
|
mergeTaskWorktree(taskId: string): Promise<TaskRecord>;
|
|
133
134
|
/** 丢弃任务 worktree(UI「丢弃」按钮的路由实现)。 */
|
|
@@ -55,6 +55,9 @@ export class TaskService {
|
|
|
55
55
|
constructor(deps) {
|
|
56
56
|
this.deps = deps;
|
|
57
57
|
}
|
|
58
|
+
get store() {
|
|
59
|
+
return typeof this.deps.store === 'function' ? this.deps.store() : this.deps.store;
|
|
60
|
+
}
|
|
58
61
|
/**
|
|
59
62
|
* task_run 工具的实现:落任务表 + 点火独立流,立即返回。
|
|
60
63
|
* 任何同步可知的失败(无 sidecar、worktree 建不起来)都抛给工具层,
|
|
@@ -76,13 +79,13 @@ export class TaskService {
|
|
|
76
79
|
const dependsOn = input.dependsOn?.length ? input.dependsOn : null;
|
|
77
80
|
let blockedBy = [];
|
|
78
81
|
if (dependsOn) {
|
|
79
|
-
const deps = dependsOn.map((id) => {
|
|
80
|
-
const dep = this.
|
|
82
|
+
const deps = await Promise.all(dependsOn.map(async (id) => {
|
|
83
|
+
const dep = await this.store.getTask(id);
|
|
81
84
|
if (!dep || dep.chatId !== input.chatId) {
|
|
82
85
|
throw new Error(`task_run: 依赖任务不存在或不属于本会话:${id}`);
|
|
83
86
|
}
|
|
84
87
|
return dep;
|
|
85
|
-
});
|
|
88
|
+
}));
|
|
86
89
|
const failed = deps.filter((d) => d.status === 'failed');
|
|
87
90
|
if (failed.length) {
|
|
88
91
|
throw new Error(`task_run: 依赖任务已失败(${failed.map((d) => d.id.slice(0, 8)).join('、')})——`
|
|
@@ -108,7 +111,7 @@ export class TaskService {
|
|
|
108
111
|
}
|
|
109
112
|
}
|
|
110
113
|
const blocked = blockedBy.length > 0;
|
|
111
|
-
const record = this.
|
|
114
|
+
const record = await this.store.createTask({
|
|
112
115
|
chatId: input.chatId,
|
|
113
116
|
task,
|
|
114
117
|
worktreePath,
|
|
@@ -117,7 +120,7 @@ export class TaskService {
|
|
|
117
120
|
initialStatus: blocked ? 'blocked' : 'running',
|
|
118
121
|
});
|
|
119
122
|
const recordId = `task:${record.id}`;
|
|
120
|
-
this.
|
|
123
|
+
await this.store.updateTask(record.id, { recordId });
|
|
121
124
|
if (!blocked) {
|
|
122
125
|
this.ignite(record.id, input.chatId, task, recordId, worktreePath);
|
|
123
126
|
}
|
|
@@ -137,7 +140,7 @@ export class TaskService {
|
|
|
137
140
|
const text = message.trim();
|
|
138
141
|
if (!text)
|
|
139
142
|
return { success: false, error: 'task_send: message 不能为空' };
|
|
140
|
-
const task = this.
|
|
143
|
+
const task = await this.store.getTask(taskId);
|
|
141
144
|
if (!task || task.chatId !== chatId) {
|
|
142
145
|
return { success: false, error: `任务不存在:${taskId}` };
|
|
143
146
|
}
|
|
@@ -178,15 +181,15 @@ export class TaskService {
|
|
|
178
181
|
* completed 的点火;任一依赖 failed 的标 failed(fail fast,错误
|
|
179
182
|
* 指明是哪个依赖挂了)。
|
|
180
183
|
*/
|
|
181
|
-
maybeUnblock(chatId) {
|
|
182
|
-
const blocked = this.
|
|
183
|
-
.listTasks(chatId, 100)
|
|
184
|
+
async maybeUnblock(chatId) {
|
|
185
|
+
const blocked = (await this.store
|
|
186
|
+
.listTasks(chatId, 100))
|
|
184
187
|
.filter((t) => t.status === 'blocked' && t.dependsOn?.length);
|
|
185
188
|
for (const task of blocked) {
|
|
186
|
-
const deps = task.dependsOn.map((id) => this.
|
|
189
|
+
const deps = await Promise.all(task.dependsOn.map((id) => this.store.getTask(id)));
|
|
187
190
|
const failedDep = deps.find((d) => d?.status === 'failed');
|
|
188
191
|
if (failedDep) {
|
|
189
|
-
this.
|
|
192
|
+
await this.store.updateTask(task.id, {
|
|
190
193
|
status: 'failed',
|
|
191
194
|
error: `依赖任务失败:${failedDep.id.slice(0, 8)}(${failedDep.error ?? '无错误信息'})`,
|
|
192
195
|
});
|
|
@@ -195,7 +198,7 @@ export class TaskService {
|
|
|
195
198
|
}
|
|
196
199
|
const allCompleted = deps.every((d) => d?.status === 'completed');
|
|
197
200
|
if (allCompleted) {
|
|
198
|
-
this.
|
|
201
|
+
await this.store.updateTask(task.id, { status: 'running' });
|
|
199
202
|
this.ignite(task.id, task.chatId, task.task, task.recordId ?? `task:${task.id}`, task.worktreePath);
|
|
200
203
|
this.deps.broadcast?.('task-updated', { chatId, taskId: task.id });
|
|
201
204
|
}
|
|
@@ -217,7 +220,7 @@ export class TaskService {
|
|
|
217
220
|
.finally(() => {
|
|
218
221
|
this.running.delete(taskId);
|
|
219
222
|
// 一个任务到终态,可能让同 chat 的 blocked 任务依赖就绪——调度点火。
|
|
220
|
-
this.maybeUnblock(chatId);
|
|
223
|
+
void this.maybeUnblock(chatId);
|
|
221
224
|
});
|
|
222
225
|
this.running.set(taskId, entry);
|
|
223
226
|
}
|
|
@@ -235,7 +238,7 @@ export class TaskService {
|
|
|
235
238
|
timeline: entry.timeline,
|
|
236
239
|
live: true,
|
|
237
240
|
});
|
|
238
|
-
this.
|
|
241
|
+
void this.store.saveTaskProcess?.(taskId, JSON.stringify(entry.timeline));
|
|
239
242
|
};
|
|
240
243
|
if (when === 'immediate') {
|
|
241
244
|
this.cancelProcessFlush(taskId);
|
|
@@ -259,7 +262,7 @@ export class TaskService {
|
|
|
259
262
|
const getSupervisor = this.deps.getSupervisor ?? getSidecarSupervisor;
|
|
260
263
|
const supervisor = getSupervisor();
|
|
261
264
|
if (!supervisor) {
|
|
262
|
-
this.
|
|
265
|
+
await this.store.updateTask(taskId, {
|
|
263
266
|
status: 'failed',
|
|
264
267
|
error: 'sidecar 未运行',
|
|
265
268
|
});
|
|
@@ -267,7 +270,7 @@ export class TaskService {
|
|
|
267
270
|
return;
|
|
268
271
|
}
|
|
269
272
|
const settings = llmService.getSettings();
|
|
270
|
-
const project = this.deps.resolveChatProject(chatId);
|
|
273
|
+
const project = await this.deps.resolveChatProject(chatId);
|
|
271
274
|
// 4.6b 整合:worktree 任务的围栏根 = worktree 路径(更紧——任务摸不到
|
|
272
275
|
// 主检出);否则沿用项目根。writableRoots 同理收窄。
|
|
273
276
|
const fenceRoot = worktreePath ?? project?.folderPath ?? null;
|
|
@@ -397,14 +400,14 @@ export class TaskService {
|
|
|
397
400
|
traceId = failureTraceId;
|
|
398
401
|
}
|
|
399
402
|
}
|
|
400
|
-
this.
|
|
403
|
+
await this.store.updateTask(taskId, {
|
|
401
404
|
status,
|
|
402
405
|
answer: answer.trim() || null,
|
|
403
406
|
error,
|
|
404
407
|
...(traceId ? { traceId } : {}),
|
|
405
408
|
});
|
|
406
409
|
this.cancelProcessFlush(taskId);
|
|
407
|
-
this.
|
|
410
|
+
await this.store.saveTaskProcess?.(taskId, JSON.stringify(entry.timeline));
|
|
408
411
|
this.deps.broadcast?.('task-updated', { chatId, taskId });
|
|
409
412
|
this.deps.broadcast?.('task-process', {
|
|
410
413
|
chatId,
|
|
@@ -414,15 +417,15 @@ export class TaskService {
|
|
|
414
417
|
});
|
|
415
418
|
}
|
|
416
419
|
/** task_status 工具:单任务或本 chat 全部任务的快照。 */
|
|
417
|
-
status(chatId, taskId) {
|
|
420
|
+
async status(chatId, taskId) {
|
|
418
421
|
if (taskId) {
|
|
419
|
-
const task = this.
|
|
422
|
+
const task = await this.store.getTask(taskId);
|
|
420
423
|
if (!task || task.chatId !== chatId) {
|
|
421
424
|
return { success: false, error: `任务不存在:${taskId}` };
|
|
422
425
|
}
|
|
423
426
|
return { success: true, task: this.toModelView(task) };
|
|
424
427
|
}
|
|
425
|
-
const tasks = this.
|
|
428
|
+
const tasks = await this.store.listTasks(chatId, 50);
|
|
426
429
|
return {
|
|
427
430
|
success: true,
|
|
428
431
|
total: tasks.length,
|
|
@@ -430,8 +433,8 @@ export class TaskService {
|
|
|
430
433
|
};
|
|
431
434
|
}
|
|
432
435
|
/** task_result 工具:取终态任务的完整结果。 */
|
|
433
|
-
result(chatId, taskId) {
|
|
434
|
-
const task = this.
|
|
436
|
+
async result(chatId, taskId) {
|
|
437
|
+
const task = await this.store.getTask(taskId);
|
|
435
438
|
if (!task || task.chatId !== chatId) {
|
|
436
439
|
return { success: false, error: `任务不存在:${taskId}` };
|
|
437
440
|
}
|
|
@@ -473,8 +476,8 @@ export class TaskService {
|
|
|
473
476
|
* 右侧过程栏:当前推理时间线。running 且进程内有流 = live;
|
|
474
477
|
* 表上 running 但本进程没流 = 上次崩溃残留(stale)。
|
|
475
478
|
*/
|
|
476
|
-
getProcess(taskId) {
|
|
477
|
-
const task = this.
|
|
479
|
+
async getProcess(taskId) {
|
|
480
|
+
const task = await this.store.getTask(taskId);
|
|
478
481
|
if (!task)
|
|
479
482
|
return null;
|
|
480
483
|
const entry = this.running.get(taskId);
|
|
@@ -509,7 +512,7 @@ export class TaskService {
|
|
|
509
512
|
}
|
|
510
513
|
/** 合并任务 worktree 到主仓(UI「合并到主仓」按钮的路由实现)。 */
|
|
511
514
|
async mergeTaskWorktree(taskId) {
|
|
512
|
-
const task = this.
|
|
515
|
+
const task = await this.store.getTask(taskId);
|
|
513
516
|
if (!task)
|
|
514
517
|
throw new Error(`任务不存在:${taskId}`);
|
|
515
518
|
if (!task.worktreePath || !task.worktreeBranch) {
|
|
@@ -523,13 +526,13 @@ export class TaskService {
|
|
|
523
526
|
}
|
|
524
527
|
const name = path.basename(task.worktreePath);
|
|
525
528
|
await this.deps.worktreeService.mergeWorktree(task.chatId, name, `task(${taskId.slice(0, 8)}): ${task.task.slice(0, 60)}`);
|
|
526
|
-
const updated = this.
|
|
529
|
+
const updated = await this.store.updateTask(taskId, { worktreeState: 'merged' });
|
|
527
530
|
this.deps.broadcast?.('task-updated', { chatId: task.chatId, taskId });
|
|
528
531
|
return updated;
|
|
529
532
|
}
|
|
530
533
|
/** 丢弃任务 worktree(UI「丢弃」按钮的路由实现)。 */
|
|
531
534
|
async discardTaskWorktree(taskId) {
|
|
532
|
-
const task = this.
|
|
535
|
+
const task = await this.store.getTask(taskId);
|
|
533
536
|
if (!task)
|
|
534
537
|
throw new Error(`任务不存在:${taskId}`);
|
|
535
538
|
if (!task.worktreePath) {
|
|
@@ -545,7 +548,7 @@ export class TaskService {
|
|
|
545
548
|
await this.deps.worktreeService.removeWorktree(task.chatId, name, {
|
|
546
549
|
deleteBranch: true,
|
|
547
550
|
});
|
|
548
|
-
const updated = this.
|
|
551
|
+
const updated = await this.store.updateTask(taskId, { worktreeState: 'discarded' });
|
|
549
552
|
this.deps.broadcast?.('task-updated', { chatId: task.chatId, taskId });
|
|
550
553
|
return updated;
|
|
551
554
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 回合产物文件收集(「本轮写了哪些文件」列表)。
|
|
3
|
+
*
|
|
4
|
+
* 四个来源取并集:
|
|
5
|
+
*
|
|
6
|
+
* 1. 工作区扫描(递归):回合的可写根(项目根 + 场景包工作区,与 exec
|
|
7
|
+
* 沙箱同源)里 mtime/birthtime ≥ 回合开始时间的文件。覆盖脚本/命令
|
|
8
|
+
* 间接写出的产物(如运行脚本生成的文档、报表)——这类路径无法从
|
|
9
|
+
* 工具参数解析。
|
|
10
|
+
* 2. 显式写工具参数:local_write_file / local_edit_file 的 path。覆盖
|
|
11
|
+
* 「完整权限」模式下写到可写根之外的路径(如 ~/Downloads)。
|
|
12
|
+
* 3. exec cwd 浅扫描(仅顶层一层、跳过点文件):local_exec_shell /
|
|
13
|
+
* local_run_snippet 的实际工作目录(显式 cwd;缺省按 executor 规则
|
|
14
|
+
* 回落到项目根或 home)。覆盖「脚本在 cwd 落盘」——无项目对话的
|
|
15
|
+
* exec 默认 cwd 是 home,产物(如 ~/季度报告.pdf)不在任何递归根里。
|
|
16
|
+
* 4. 命令/代码文本里的绝对路径字面量:exec 命令、snippet 代码中出现的
|
|
17
|
+
* 绝对路径(含 ~/ 前缀与引号包裹形式),stat + 时间水位线验证后并入。
|
|
18
|
+
* 覆盖脚本写到与 cwd 无关的任意位置(如 doc.save('/tmp/x/a.pdf'))。
|
|
19
|
+
*
|
|
20
|
+
* 来源 3/4 都是「猜候选、靠 stat + mtime/birthtime 水位线证伪」:命令里
|
|
21
|
+
* 提到的既有文件(ls /etc/hosts)时间戳旧、不会被误收;不存在的路径
|
|
22
|
+
* stat 即失败。因此解析不需要可靠——与沙箱围栏「命令内嵌绝对路径无法
|
|
23
|
+
* 可靠解析所以不拦」不同,展示面可以承受启发式,漏报才伤体验。
|
|
24
|
+
*
|
|
25
|
+
* 单趟末次扫描、无基线快照:kind 标签靠 birthtime(macOS/Windows/新版
|
|
26
|
+
* Linux 文件系统可用);birthtime 不可用时降级为 'modified',列表本身
|
|
27
|
+
* 的准确性不依赖 birthtime。
|
|
28
|
+
*
|
|
29
|
+
* 扫描是有界 best-effort:忽略依赖/构建缓存目录,深度与遍历条目数有硬顶,
|
|
30
|
+
* 超限直接截断返回已收集部分——产物列表是展示面,永远不该拖垮回合收尾。
|
|
31
|
+
*/
|
|
32
|
+
export interface TurnFile {
|
|
33
|
+
/** 绝对路径(点击打开直接用)。 */
|
|
34
|
+
path: string;
|
|
35
|
+
/** created = 本轮新建;modified = 已有文件被改动。 */
|
|
36
|
+
kind: 'created' | 'modified';
|
|
37
|
+
/** 字节数(展示用)。 */
|
|
38
|
+
size: number;
|
|
39
|
+
}
|
|
40
|
+
/** 扫描入参里只需要工具行动的这几个字段(与 router 的 executedActions 行结构对齐)。 */
|
|
41
|
+
export interface TurnFileAction {
|
|
42
|
+
tool?: unknown;
|
|
43
|
+
arguments?: unknown;
|
|
44
|
+
success?: unknown;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 收集本轮产物文件。`sinceMs` 是回合开始的 epoch ms;`projectRoot` 用于把
|
|
48
|
+
* 写工具的相对路径参数解析成绝对路径(与 local-executor 的项目根解析一致)。
|
|
49
|
+
* `homeDir` 仅测试注入用,默认 os.homedir()——exec 缺省 cwd 的回落值。
|
|
50
|
+
*/
|
|
51
|
+
export declare function collectTurnFiles(options: {
|
|
52
|
+
roots: string[];
|
|
53
|
+
sinceMs: number;
|
|
54
|
+
actions?: readonly TurnFileAction[];
|
|
55
|
+
projectRoot?: string | null;
|
|
56
|
+
homeDir?: string;
|
|
57
|
+
}): Promise<TurnFile[]>;
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 回合产物文件收集(「本轮写了哪些文件」列表)。
|
|
3
|
+
*
|
|
4
|
+
* 四个来源取并集:
|
|
5
|
+
*
|
|
6
|
+
* 1. 工作区扫描(递归):回合的可写根(项目根 + 场景包工作区,与 exec
|
|
7
|
+
* 沙箱同源)里 mtime/birthtime ≥ 回合开始时间的文件。覆盖脚本/命令
|
|
8
|
+
* 间接写出的产物(如运行脚本生成的文档、报表)——这类路径无法从
|
|
9
|
+
* 工具参数解析。
|
|
10
|
+
* 2. 显式写工具参数:local_write_file / local_edit_file 的 path。覆盖
|
|
11
|
+
* 「完整权限」模式下写到可写根之外的路径(如 ~/Downloads)。
|
|
12
|
+
* 3. exec cwd 浅扫描(仅顶层一层、跳过点文件):local_exec_shell /
|
|
13
|
+
* local_run_snippet 的实际工作目录(显式 cwd;缺省按 executor 规则
|
|
14
|
+
* 回落到项目根或 home)。覆盖「脚本在 cwd 落盘」——无项目对话的
|
|
15
|
+
* exec 默认 cwd 是 home,产物(如 ~/季度报告.pdf)不在任何递归根里。
|
|
16
|
+
* 4. 命令/代码文本里的绝对路径字面量:exec 命令、snippet 代码中出现的
|
|
17
|
+
* 绝对路径(含 ~/ 前缀与引号包裹形式),stat + 时间水位线验证后并入。
|
|
18
|
+
* 覆盖脚本写到与 cwd 无关的任意位置(如 doc.save('/tmp/x/a.pdf'))。
|
|
19
|
+
*
|
|
20
|
+
* 来源 3/4 都是「猜候选、靠 stat + mtime/birthtime 水位线证伪」:命令里
|
|
21
|
+
* 提到的既有文件(ls /etc/hosts)时间戳旧、不会被误收;不存在的路径
|
|
22
|
+
* stat 即失败。因此解析不需要可靠——与沙箱围栏「命令内嵌绝对路径无法
|
|
23
|
+
* 可靠解析所以不拦」不同,展示面可以承受启发式,漏报才伤体验。
|
|
24
|
+
*
|
|
25
|
+
* 单趟末次扫描、无基线快照:kind 标签靠 birthtime(macOS/Windows/新版
|
|
26
|
+
* Linux 文件系统可用);birthtime 不可用时降级为 'modified',列表本身
|
|
27
|
+
* 的准确性不依赖 birthtime。
|
|
28
|
+
*
|
|
29
|
+
* 扫描是有界 best-effort:忽略依赖/构建缓存目录,深度与遍历条目数有硬顶,
|
|
30
|
+
* 超限直接截断返回已收集部分——产物列表是展示面,永远不该拖垮回合收尾。
|
|
31
|
+
*/
|
|
32
|
+
import fs from 'node:fs/promises';
|
|
33
|
+
import os from 'node:os';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
/** 这些目录要么体量巨大(node_modules/target),要么是框架内部状态(.steerable),扫了只有噪音。 */
|
|
36
|
+
const IGNORED_DIR_NAMES = new Set([
|
|
37
|
+
'node_modules',
|
|
38
|
+
'.git',
|
|
39
|
+
'.hg',
|
|
40
|
+
'.svn',
|
|
41
|
+
'__pycache__',
|
|
42
|
+
'.venv',
|
|
43
|
+
'venv',
|
|
44
|
+
'.tox',
|
|
45
|
+
'.next',
|
|
46
|
+
'.nuxt',
|
|
47
|
+
'.cache',
|
|
48
|
+
'target',
|
|
49
|
+
'.steerable',
|
|
50
|
+
]);
|
|
51
|
+
const IGNORED_FILE_NAMES = new Set(['.DS_Store']);
|
|
52
|
+
/** 单次扫描的遍历上限:深度 10、条目 10 万、结果 100 条。 */
|
|
53
|
+
const MAX_DEPTH = 10;
|
|
54
|
+
const MAX_VISITED = 100_000;
|
|
55
|
+
const MAX_FILES = 100;
|
|
56
|
+
/** 显式写工具:参数里的 path 并入产物列表(覆盖可写根之外的写入)。 */
|
|
57
|
+
const WRITE_TOOL_NAMES = new Set(['local_write_file', 'local_edit_file']);
|
|
58
|
+
/** exec 类工具:cwd 浅扫描与命令文本路径字面量的来源。 */
|
|
59
|
+
const EXEC_TOOL_NAMES = new Set(['local_exec_shell', 'local_run_snippet']);
|
|
60
|
+
/** 每条命令/代码文本最多提取的路径字面量个数(防超长命令刷 stat)。 */
|
|
61
|
+
const MAX_PATH_LITERALS_PER_ACTION = 50;
|
|
62
|
+
/**
|
|
63
|
+
* Linux overlay/tmpfs 常把 mtime/birthtime 截到 1s。回合开始后立刻写出的
|
|
64
|
+
* 产物会 stat 成略早于 sinceMs,被水位线丢掉。1s 容差远小于一轮对话,
|
|
65
|
+
* 只影响展示列表,允许把回合前 1s 内的文件算进来。
|
|
66
|
+
*/
|
|
67
|
+
const TOUCHED_SLACK_MS = 1000;
|
|
68
|
+
/**
|
|
69
|
+
* 引号包裹的绝对路径字面量:python 代码里的 doc.save('/x/a.pdf')、shell
|
|
70
|
+
* 里的 "/x/a.pdf"。要求以 /、~/ 或 Windows 盘符开头、带扩展名结尾。
|
|
71
|
+
* 贪婪匹配到闭引号再回溯到最后一个点——目录名里带点(.venv/python3.11)
|
|
72
|
+
* 不会把路径截断在中间。
|
|
73
|
+
*/
|
|
74
|
+
const QUOTED_PATH_PATTERN = /['"]((?:~\/|\/|[A-Za-z]:[\\/])[^'"]{1,300}\.[A-Za-z0-9]{1,10})['"]/g;
|
|
75
|
+
/**
|
|
76
|
+
* 裸绝对路径字面量(无引号、无空格):shell 重定向/参数里常见的写法。
|
|
77
|
+
* 排除引号/空白/shell 元字符;URL 的 // 开头被 (?!\/) 挡掉;同样贪婪到
|
|
78
|
+
* token 尾再回溯到最后一个点,(?![A-Za-z0-9]) 保证扩展名不被截断。
|
|
79
|
+
*/
|
|
80
|
+
const BARE_PATH_PATTERN = /(?:~\/|\/(?!\/))[^\s"'`<>|;,()[\]{}\\*?]{1,300}\.[A-Za-z0-9]{1,10}(?![A-Za-z0-9])|[A-Za-z]:\\[^\s"'`<>|;,*?]{1,300}\.[A-Za-z0-9]{1,10}(?![A-Za-z0-9])/g;
|
|
81
|
+
/**
|
|
82
|
+
* 收集本轮产物文件。`sinceMs` 是回合开始的 epoch ms;`projectRoot` 用于把
|
|
83
|
+
* 写工具的相对路径参数解析成绝对路径(与 local-executor 的项目根解析一致)。
|
|
84
|
+
* `homeDir` 仅测试注入用,默认 os.homedir()——exec 缺省 cwd 的回落值。
|
|
85
|
+
*/
|
|
86
|
+
export async function collectTurnFiles(options) {
|
|
87
|
+
const { roots, sinceMs, projectRoot = null } = options;
|
|
88
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
89
|
+
const byPath = new Map();
|
|
90
|
+
for (const root of roots) {
|
|
91
|
+
await scanRoot(root, sinceMs, byPath);
|
|
92
|
+
}
|
|
93
|
+
const shallowRoots = new Set();
|
|
94
|
+
for (const action of options.actions ?? []) {
|
|
95
|
+
await collectWriteToolPath(action, sinceMs, projectRoot, byPath);
|
|
96
|
+
const cwd = execCwdOf(action, projectRoot, homeDir);
|
|
97
|
+
if (cwd && !isCoveredByRoots(cwd, roots))
|
|
98
|
+
shallowRoots.add(cwd);
|
|
99
|
+
await collectPathLiterals(action, sinceMs, homeDir, byPath);
|
|
100
|
+
}
|
|
101
|
+
for (const dir of shallowRoots) {
|
|
102
|
+
await scanShallow(dir, sinceMs, byPath);
|
|
103
|
+
}
|
|
104
|
+
return [...byPath.values()]
|
|
105
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
106
|
+
.slice(0, MAX_FILES);
|
|
107
|
+
}
|
|
108
|
+
async function scanRoot(root, sinceMs, out) {
|
|
109
|
+
const state = { visited: 0 };
|
|
110
|
+
await walk(path.resolve(root), sinceMs, 0, state, out);
|
|
111
|
+
}
|
|
112
|
+
async function walk(dir, sinceMs, depth, state, out) {
|
|
113
|
+
if (depth > MAX_DEPTH || state.visited >= MAX_VISITED)
|
|
114
|
+
return;
|
|
115
|
+
let entries;
|
|
116
|
+
try {
|
|
117
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// 根目录被删/无权限:跳过该根,不影响其他根。
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
if (state.visited >= MAX_VISITED)
|
|
125
|
+
return;
|
|
126
|
+
state.visited += 1;
|
|
127
|
+
const full = path.join(dir, entry.name);
|
|
128
|
+
// 不跟随符号链接:避免链接环把扫描拖出根外。
|
|
129
|
+
if (entry.isSymbolicLink())
|
|
130
|
+
continue;
|
|
131
|
+
if (entry.isDirectory()) {
|
|
132
|
+
if (IGNORED_DIR_NAMES.has(entry.name))
|
|
133
|
+
continue;
|
|
134
|
+
await walk(full, sinceMs, depth + 1, state, out);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!entry.isFile() || IGNORED_FILE_NAMES.has(entry.name))
|
|
138
|
+
continue;
|
|
139
|
+
const file = await statTurnFile(full, sinceMs);
|
|
140
|
+
if (file)
|
|
141
|
+
out.set(full, file);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** stat 一个文件,命中「本轮触碰过」则返回 TurnFile,否则 null。 */
|
|
145
|
+
async function statTurnFile(full, sinceMs) {
|
|
146
|
+
let stat;
|
|
147
|
+
try {
|
|
148
|
+
stat = await fs.stat(full);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// 扫描窗口内被删掉的文件直接略过。
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
// 路径字面量可能指到目录(如 xxx.app 包);产物列表只收文件。
|
|
155
|
+
if (!stat.isFile())
|
|
156
|
+
return null;
|
|
157
|
+
const touched = stat.mtimeMs + TOUCHED_SLACK_MS >= sinceMs ||
|
|
158
|
+
stat.birthtimeMs + TOUCHED_SLACK_MS >= sinceMs;
|
|
159
|
+
if (!touched)
|
|
160
|
+
return null;
|
|
161
|
+
return { path: full, kind: kindOf(stat, sinceMs), size: stat.size };
|
|
162
|
+
}
|
|
163
|
+
function kindOf(stat, sinceMs) {
|
|
164
|
+
// birthtime 不可用的文件系统返回 0/负值——此时无法区分新建与修改,
|
|
165
|
+
// 统一标 modified(标签降级,不漏文件)。
|
|
166
|
+
return stat.birthtimeMs >= sinceMs ? 'created' : 'modified';
|
|
167
|
+
}
|
|
168
|
+
async function collectWriteToolPath(action, sinceMs, projectRoot, out) {
|
|
169
|
+
if (typeof action.tool !== 'string' || !WRITE_TOOL_NAMES.has(action.tool))
|
|
170
|
+
return;
|
|
171
|
+
// success === false 的调用没写成;undefined(流中未落定)按成功处理——
|
|
172
|
+
// 文件是否真存在由下面的 stat 把关。
|
|
173
|
+
if (action.success === false)
|
|
174
|
+
return;
|
|
175
|
+
const args = action.arguments;
|
|
176
|
+
if (!args || typeof args !== 'object')
|
|
177
|
+
return;
|
|
178
|
+
const raw = args.path;
|
|
179
|
+
if (typeof raw !== 'string' || !raw.trim())
|
|
180
|
+
return;
|
|
181
|
+
const full = path.isAbsolute(raw)
|
|
182
|
+
? path.normalize(raw)
|
|
183
|
+
: projectRoot
|
|
184
|
+
? path.resolve(projectRoot, raw)
|
|
185
|
+
: null;
|
|
186
|
+
if (!full || out.has(full))
|
|
187
|
+
return;
|
|
188
|
+
const file = await statTurnFile(full, sinceMs);
|
|
189
|
+
if (file)
|
|
190
|
+
out.set(full, file);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* exec 行动的实际工作目录(与 tool-router / local-executor 的解析规则对齐):
|
|
194
|
+
* 显式 cwd 展开 ~ 后按绝对/相对解析(相对在项目模式下按项目根、否则按
|
|
195
|
+
* 进程 cwd);缺省 cwd 回落到项目根,无项目时回落到 home——无项目对话里
|
|
196
|
+
* 脚本产物最常见的落点。
|
|
197
|
+
*/
|
|
198
|
+
function execCwdOf(action, projectRoot, homeDir) {
|
|
199
|
+
if (typeof action.tool !== 'string' || !EXEC_TOOL_NAMES.has(action.tool))
|
|
200
|
+
return null;
|
|
201
|
+
const args = action.arguments;
|
|
202
|
+
if (!args || typeof args !== 'object')
|
|
203
|
+
return null;
|
|
204
|
+
const raw = args.cwd;
|
|
205
|
+
const cwdArg = typeof raw === 'string' ? raw.trim() : '';
|
|
206
|
+
if (!cwdArg)
|
|
207
|
+
return projectRoot ?? homeDir;
|
|
208
|
+
const expanded = cwdArg.startsWith('~') ? path.join(homeDir, cwdArg.slice(1)) : cwdArg;
|
|
209
|
+
if (path.isAbsolute(expanded))
|
|
210
|
+
return path.normalize(expanded);
|
|
211
|
+
return projectRoot ? path.resolve(projectRoot, expanded) : path.resolve(expanded);
|
|
212
|
+
}
|
|
213
|
+
/** cwd 已落在某个递归根之内时无需再浅扫(递归扫描已覆盖且更深)。 */
|
|
214
|
+
function isCoveredByRoots(cwd, roots) {
|
|
215
|
+
return roots.some((root) => {
|
|
216
|
+
const rel = path.relative(path.resolve(root), cwd);
|
|
217
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* exec cwd 的浅扫描:只看顶层一层、跳过点文件。home 顶层的变化几乎全是
|
|
222
|
+
* 工具状态(.zsh_history 之类),点文件过滤掉它们;真正的产物文档极少
|
|
223
|
+
* 以点开头。浅扫不递归——home 这种根递归起来既慢又全是无关变化。
|
|
224
|
+
*/
|
|
225
|
+
async function scanShallow(dir, sinceMs, out) {
|
|
226
|
+
let entries;
|
|
227
|
+
try {
|
|
228
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// cwd 被删/无权限:跳过,不影响其他来源。
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
for (const entry of entries) {
|
|
235
|
+
if (entry.name.startsWith('.') || IGNORED_FILE_NAMES.has(entry.name))
|
|
236
|
+
continue;
|
|
237
|
+
if (entry.isSymbolicLink() || !entry.isFile())
|
|
238
|
+
continue;
|
|
239
|
+
const full = path.join(dir, entry.name);
|
|
240
|
+
if (out.has(full))
|
|
241
|
+
continue;
|
|
242
|
+
const file = await statTurnFile(full, sinceMs);
|
|
243
|
+
if (file)
|
|
244
|
+
out.set(full, file);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* 从 exec 命令 / snippet 代码文本里提取绝对路径字面量,stat + 水位线
|
|
249
|
+
* 验证后并入。覆盖脚本写到与 cwd 无关的任意位置。local_run_script 的
|
|
250
|
+
* 脚本内容在注册表里、行动参数只有 scriptId,不在此覆盖(其 cwd 语义
|
|
251
|
+
* 与 exec_shell 相同,浅扫描已兜底常见落点)。
|
|
252
|
+
*/
|
|
253
|
+
async function collectPathLiterals(action, sinceMs, homeDir, out) {
|
|
254
|
+
if (typeof action.tool !== 'string' || !EXEC_TOOL_NAMES.has(action.tool))
|
|
255
|
+
return;
|
|
256
|
+
const args = action.arguments;
|
|
257
|
+
if (!args || typeof args !== 'object')
|
|
258
|
+
return;
|
|
259
|
+
const record = args;
|
|
260
|
+
const text = [record.command, record.code]
|
|
261
|
+
.filter((value) => typeof value === 'string')
|
|
262
|
+
.join('\n');
|
|
263
|
+
if (!text)
|
|
264
|
+
return;
|
|
265
|
+
const candidates = new Set();
|
|
266
|
+
for (const pattern of [QUOTED_PATH_PATTERN, BARE_PATH_PATTERN]) {
|
|
267
|
+
pattern.lastIndex = 0;
|
|
268
|
+
for (const match of text.matchAll(pattern)) {
|
|
269
|
+
if (candidates.size >= MAX_PATH_LITERALS_PER_ACTION)
|
|
270
|
+
break;
|
|
271
|
+
const literal = match[1] ?? match[0];
|
|
272
|
+
const expanded = literal.startsWith('~/')
|
|
273
|
+
? path.join(homeDir, literal.slice(2))
|
|
274
|
+
: literal;
|
|
275
|
+
candidates.add(path.normalize(expanded));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
for (const full of candidates) {
|
|
279
|
+
if (out.has(full))
|
|
280
|
+
continue;
|
|
281
|
+
const file = await statTurnFile(full, sinceMs);
|
|
282
|
+
if (file)
|
|
283
|
+
out.set(full, file);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -35,7 +35,7 @@ export declare function slugifyWorktreeName(raw: string): string;
|
|
|
35
35
|
export declare class WorktreeService {
|
|
36
36
|
private readonly deps;
|
|
37
37
|
constructor(deps: {
|
|
38
|
-
resolveProject: (chatId: string) => WorktreeProject | null
|
|
38
|
+
resolveProject: (chatId: string) => Promise<WorktreeProject | null>;
|
|
39
39
|
runGit?: GitRunner;
|
|
40
40
|
});
|
|
41
41
|
private git;
|