@akira-tl/forgerelay 0.8.2 → 0.8.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.
- package/CHANGELOG.md +16 -0
- package/capabilities/workspace-tasks/GUIDE.md +50 -0
- package/dist/capabilities.js +6 -0
- package/dist/capability-registry.js +96 -21
- package/dist/config.js +11 -0
- package/dist/remote-workspace-relay.js +15 -0
- package/dist/server.js +449 -34
- package/dist/subagents/sessions/capability.js +3 -0
- package/dist/workspace-task-reminders.js +42 -0
- package/dist/workspace-tasks.js +420 -0
- package/dist/workspaces.js +62 -30
- package/docs/chatgpt-coding-workflow.md +13 -3
- package/docs/configuration.md +46 -11
- package/package.json +2 -2
- package/scripts/debug/accept.mjs +298 -0
|
@@ -19,6 +19,9 @@ export class SubagentSessionCapability {
|
|
|
19
19
|
this.ownerAliveOverride = options.ownerAlive;
|
|
20
20
|
}
|
|
21
21
|
async run(input, context, options) {
|
|
22
|
+
if (!context.workspaceRoot) {
|
|
23
|
+
throw new CapabilityError("capability_unavailable", "subagent.session requires a filesystem-backed Workspace.");
|
|
24
|
+
}
|
|
22
25
|
const manager = new SubagentSessionManager(this.config, {
|
|
23
26
|
launch: (request) => this.launch(request),
|
|
24
27
|
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const TASK_REMINDER = "Reminder: this Workspace has unfinished active Tasks. Review workspace.tasks and update Task state when material progress, requirements, blockers, or conclusions changed.";
|
|
2
|
+
export class WorkspaceTaskReminderTracker {
|
|
3
|
+
interval;
|
|
4
|
+
tasks;
|
|
5
|
+
callsSinceUpdate = new Map();
|
|
6
|
+
constructor(interval, tasks) {
|
|
7
|
+
this.interval = interval;
|
|
8
|
+
this.tasks = tasks;
|
|
9
|
+
}
|
|
10
|
+
reset(workspaceId) {
|
|
11
|
+
this.callsSinceUpdate.delete(workspaceId);
|
|
12
|
+
}
|
|
13
|
+
forget(workspaceId) {
|
|
14
|
+
this.callsSinceUpdate.delete(workspaceId);
|
|
15
|
+
}
|
|
16
|
+
recordWork(workspaceId) {
|
|
17
|
+
if (this.interval === 0)
|
|
18
|
+
return undefined;
|
|
19
|
+
let summary;
|
|
20
|
+
try {
|
|
21
|
+
summary = this.tasks.readSummary(workspaceId);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Reminder delivery is advisory. Invalid external Task state must still be
|
|
25
|
+
// surfaced by workspace.tasks itself, not turn unrelated work into failure.
|
|
26
|
+
this.callsSinceUpdate.delete(workspaceId);
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
const hasUnfinishedActiveTasks = summary.lists.some((list) => list.state === "active" && list.unfinishedTaskCount > 0);
|
|
30
|
+
if (!hasUnfinishedActiveTasks) {
|
|
31
|
+
this.callsSinceUpdate.delete(workspaceId);
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
const next = (this.callsSinceUpdate.get(workspaceId) ?? 0) + 1;
|
|
35
|
+
if (next < this.interval) {
|
|
36
|
+
this.callsSinceUpdate.set(workspaceId, next);
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
this.callsSinceUpdate.set(workspaceId, 0);
|
|
40
|
+
return TASK_REMINDER;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, rmdirSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import * as z from "zod/v4";
|
|
5
|
+
const TASK_STATE_VERSION = 1;
|
|
6
|
+
const MAX_TASK_STATE_BYTES = 2 * 1024 * 1024;
|
|
7
|
+
const MAX_TASK_LISTS = 100;
|
|
8
|
+
const MAX_TASKS_PER_LIST = 500;
|
|
9
|
+
const MAX_LIST_NAME_LENGTH = 120;
|
|
10
|
+
const MAX_TASK_SUBJECT_LENGTH = 240;
|
|
11
|
+
const MAX_TASK_CONTENT_LENGTH = 64 * 1024;
|
|
12
|
+
const workspaceTaskSchema = z.object({
|
|
13
|
+
id: z.string().regex(/^tsk_[a-f0-9]{10}$/),
|
|
14
|
+
status: z.enum(["pending", "in_progress", "completed"]),
|
|
15
|
+
subject: z.string().min(1).max(MAX_TASK_SUBJECT_LENGTH),
|
|
16
|
+
content: z.string().max(MAX_TASK_CONTENT_LENGTH),
|
|
17
|
+
}).strict();
|
|
18
|
+
const workspaceTaskListSchema = z.object({
|
|
19
|
+
id: z.string().regex(/^tl_[a-f0-9]{10}$/),
|
|
20
|
+
name: z.string().min(1).max(MAX_LIST_NAME_LENGTH),
|
|
21
|
+
state: z.enum(["active", "archived"]),
|
|
22
|
+
revision: z.number().int().positive(),
|
|
23
|
+
tasks: z.array(workspaceTaskSchema).max(MAX_TASKS_PER_LIST),
|
|
24
|
+
}).strict();
|
|
25
|
+
const workspaceTaskStateSchema = z.object({
|
|
26
|
+
version: z.literal(TASK_STATE_VERSION),
|
|
27
|
+
revision: z.number().int().nonnegative(),
|
|
28
|
+
lists: z.array(workspaceTaskListSchema).max(MAX_TASK_LISTS),
|
|
29
|
+
}).strict().superRefine((state, context) => {
|
|
30
|
+
const listIds = new Set();
|
|
31
|
+
const taskIds = new Set();
|
|
32
|
+
state.lists.forEach((list, listIndex) => {
|
|
33
|
+
if (listIds.has(list.id)) {
|
|
34
|
+
context.addIssue({
|
|
35
|
+
code: "custom",
|
|
36
|
+
path: ["lists", listIndex, "id"],
|
|
37
|
+
message: `Duplicate Task List id ${list.id}.`,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
listIds.add(list.id);
|
|
41
|
+
list.tasks.forEach((task, taskIndex) => {
|
|
42
|
+
if (taskIds.has(task.id)) {
|
|
43
|
+
context.addIssue({
|
|
44
|
+
code: "custom",
|
|
45
|
+
path: ["lists", listIndex, "tasks", taskIndex, "id"],
|
|
46
|
+
message: `Duplicate Task id ${task.id}.`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
taskIds.add(task.id);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
export class WorkspaceTaskStore {
|
|
54
|
+
stateDir;
|
|
55
|
+
constructor(stateDir) {
|
|
56
|
+
this.stateDir = stateDir;
|
|
57
|
+
}
|
|
58
|
+
ensureWorkspace(workspaceId) {
|
|
59
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
60
|
+
const loaded = this.tryReadState(id);
|
|
61
|
+
if (loaded)
|
|
62
|
+
return snapshot(loaded.state, loaded.fingerprint);
|
|
63
|
+
return this.writeState(id, emptyState());
|
|
64
|
+
}
|
|
65
|
+
initializeWorkspace(workspaceId) {
|
|
66
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
67
|
+
const directory = this.workspaceStateDir(id);
|
|
68
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
69
|
+
try {
|
|
70
|
+
writeFileSync(this.statePath(id), `${JSON.stringify(emptyState(), null, 2)}\n`, {
|
|
71
|
+
mode: 0o600,
|
|
72
|
+
flag: "wx",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (!isErrno(error, "EEXIST"))
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
read(workspaceId) {
|
|
81
|
+
return this.ensureWorkspace(workspaceId);
|
|
82
|
+
}
|
|
83
|
+
readSummary(workspaceId) {
|
|
84
|
+
return taskSummary(this.ensureWorkspace(workspaceId));
|
|
85
|
+
}
|
|
86
|
+
inspectSummary(workspaceId) {
|
|
87
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
88
|
+
const loaded = this.tryReadState(id);
|
|
89
|
+
return loaded ? taskSummary(snapshot(loaded.state, loaded.fingerprint)) : undefined;
|
|
90
|
+
}
|
|
91
|
+
readHeaders(workspaceId, listId) {
|
|
92
|
+
return taskHeaders(this.ensureWorkspace(workspaceId), listId);
|
|
93
|
+
}
|
|
94
|
+
readTaskDetail(workspaceId, listId, taskId) {
|
|
95
|
+
return taskDetail(this.ensureWorkspace(workspaceId), listId, taskId);
|
|
96
|
+
}
|
|
97
|
+
createList(workspaceId, input) {
|
|
98
|
+
return this.mutate(workspaceId, (state) => {
|
|
99
|
+
if (state.lists.length >= MAX_TASK_LISTS) {
|
|
100
|
+
throw new Error(`Workspace Task List limit is ${MAX_TASK_LISTS}.`);
|
|
101
|
+
}
|
|
102
|
+
const list = {
|
|
103
|
+
id: `tl_${randomBytes(5).toString("hex")}`,
|
|
104
|
+
name: normalizeListName(input.name),
|
|
105
|
+
state: "active",
|
|
106
|
+
revision: 1,
|
|
107
|
+
tasks: [],
|
|
108
|
+
};
|
|
109
|
+
const position = normalizeInsertPosition(input.position, state.lists.length, "Task List");
|
|
110
|
+
state.lists.splice(position, 0, list);
|
|
111
|
+
return true;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
updateList(workspaceId, listId, input) {
|
|
115
|
+
return this.mutate(workspaceId, (state) => {
|
|
116
|
+
const index = requireListIndex(state, listId);
|
|
117
|
+
const list = state.lists[index];
|
|
118
|
+
const nextName = input.name === undefined ? list.name : normalizeListName(input.name);
|
|
119
|
+
const nextState = input.state ?? list.state;
|
|
120
|
+
const nextPosition = input.position === undefined
|
|
121
|
+
? index
|
|
122
|
+
: normalizeMovePosition(input.position, state.lists.length, "Task List");
|
|
123
|
+
const metadataChanged = nextName !== list.name || nextState !== list.state;
|
|
124
|
+
const positionChanged = nextPosition !== index;
|
|
125
|
+
if (!metadataChanged && !positionChanged)
|
|
126
|
+
return false;
|
|
127
|
+
list.name = nextName;
|
|
128
|
+
list.state = nextState;
|
|
129
|
+
list.revision += 1;
|
|
130
|
+
if (positionChanged)
|
|
131
|
+
moveArrayEntry(state.lists, index, nextPosition);
|
|
132
|
+
return true;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
deleteList(workspaceId, listId) {
|
|
136
|
+
return this.mutate(workspaceId, (state) => {
|
|
137
|
+
state.lists.splice(requireListIndex(state, listId), 1);
|
|
138
|
+
return true;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
createTask(workspaceId, listId, input) {
|
|
142
|
+
return this.mutate(workspaceId, (state) => {
|
|
143
|
+
const list = requireList(state, listId);
|
|
144
|
+
if (list.tasks.length >= MAX_TASKS_PER_LIST) {
|
|
145
|
+
throw new Error(`Task limit per Task List is ${MAX_TASKS_PER_LIST}.`);
|
|
146
|
+
}
|
|
147
|
+
const task = {
|
|
148
|
+
id: `tsk_${randomBytes(5).toString("hex")}`,
|
|
149
|
+
status: input.status ?? "pending",
|
|
150
|
+
subject: normalizeTaskSubject(input.subject),
|
|
151
|
+
content: normalizeTaskContent(input.content ?? ""),
|
|
152
|
+
};
|
|
153
|
+
const position = normalizeInsertPosition(input.position, list.tasks.length, "Task");
|
|
154
|
+
list.tasks.splice(position, 0, task);
|
|
155
|
+
list.revision += 1;
|
|
156
|
+
return true;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
updateTask(workspaceId, listId, taskId, input) {
|
|
160
|
+
return this.mutate(workspaceId, (state) => {
|
|
161
|
+
const list = requireList(state, listId);
|
|
162
|
+
const index = requireTaskIndex(list, taskId);
|
|
163
|
+
const task = list.tasks[index];
|
|
164
|
+
const nextStatus = input.status ?? task.status;
|
|
165
|
+
const nextSubject = input.subject === undefined ? task.subject : normalizeTaskSubject(input.subject);
|
|
166
|
+
const nextContent = input.content === undefined ? task.content : normalizeTaskContent(input.content);
|
|
167
|
+
const nextPosition = input.position === undefined
|
|
168
|
+
? index
|
|
169
|
+
: normalizeMovePosition(input.position, list.tasks.length, "Task");
|
|
170
|
+
const fieldsChanged = nextStatus !== task.status || nextSubject !== task.subject || nextContent !== task.content;
|
|
171
|
+
const positionChanged = nextPosition !== index;
|
|
172
|
+
if (!fieldsChanged && !positionChanged)
|
|
173
|
+
return false;
|
|
174
|
+
task.status = nextStatus;
|
|
175
|
+
task.subject = nextSubject;
|
|
176
|
+
task.content = nextContent;
|
|
177
|
+
if (positionChanged)
|
|
178
|
+
moveArrayEntry(list.tasks, index, nextPosition);
|
|
179
|
+
list.revision += 1;
|
|
180
|
+
return true;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
deleteTask(workspaceId, listId, taskId) {
|
|
184
|
+
return this.mutate(workspaceId, (state) => {
|
|
185
|
+
const list = requireList(state, listId);
|
|
186
|
+
list.tasks.splice(requireTaskIndex(list, taskId), 1);
|
|
187
|
+
list.revision += 1;
|
|
188
|
+
return true;
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
deleteWorkspace(workspaceId) {
|
|
192
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
193
|
+
rmSync(this.statePath(id), { force: true });
|
|
194
|
+
try {
|
|
195
|
+
rmdirSync(this.workspaceStateDir(id));
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
if (!isErrno(error, "ENOENT") && !isErrno(error, "ENOTEMPTY") && !isErrno(error, "EEXIST")) {
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
mutate(workspaceId, mutateState) {
|
|
204
|
+
const id = normalizeWorkspaceId(workspaceId);
|
|
205
|
+
const loaded = this.tryReadState(id);
|
|
206
|
+
const state = loaded ? cloneState(loaded.state) : emptyState();
|
|
207
|
+
if (!mutateState(state)) {
|
|
208
|
+
return loaded
|
|
209
|
+
? snapshot(loaded.state, loaded.fingerprint)
|
|
210
|
+
: this.writeState(id, state);
|
|
211
|
+
}
|
|
212
|
+
state.revision += 1;
|
|
213
|
+
return this.writeState(id, state);
|
|
214
|
+
}
|
|
215
|
+
tryReadState(workspaceId) {
|
|
216
|
+
const path = this.statePath(workspaceId);
|
|
217
|
+
let raw;
|
|
218
|
+
try {
|
|
219
|
+
raw = readFileSync(path);
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
if (isErrno(error, "ENOENT"))
|
|
223
|
+
return undefined;
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
if (raw.byteLength > MAX_TASK_STATE_BYTES) {
|
|
227
|
+
throw new Error(`Workspace Task state exceeds ${MAX_TASK_STATE_BYTES} bytes.`);
|
|
228
|
+
}
|
|
229
|
+
let parsed;
|
|
230
|
+
try {
|
|
231
|
+
parsed = JSON.parse(raw.toString("utf8"));
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
throw new Error(`Workspace Task state is not valid JSON: ${errorMessage(error)}`);
|
|
235
|
+
}
|
|
236
|
+
const validated = workspaceTaskStateSchema.safeParse(parsed);
|
|
237
|
+
if (!validated.success) {
|
|
238
|
+
const details = validated.error.issues
|
|
239
|
+
.map((issue) => `${issue.path.length > 0 ? issue.path.join(".") : "state"}: ${issue.message}`)
|
|
240
|
+
.join("; ");
|
|
241
|
+
throw new Error(`Workspace Task state has an unsupported or invalid format: ${details}`);
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
state: cloneState(validated.data),
|
|
245
|
+
fingerprint: fingerprint(raw),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
writeState(workspaceId, state) {
|
|
249
|
+
const workspaceDir = this.workspaceStateDir(workspaceId);
|
|
250
|
+
mkdirSync(workspaceDir, { recursive: true, mode: 0o700 });
|
|
251
|
+
const validated = workspaceTaskStateSchema.parse(state);
|
|
252
|
+
const serialized = `${JSON.stringify(validated, null, 2)}\n`;
|
|
253
|
+
if (Buffer.byteLength(serialized, "utf8") > MAX_TASK_STATE_BYTES) {
|
|
254
|
+
throw new Error(`Workspace Task state exceeds ${MAX_TASK_STATE_BYTES} bytes.`);
|
|
255
|
+
}
|
|
256
|
+
const statePath = this.statePath(workspaceId);
|
|
257
|
+
const tempPath = `${statePath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
258
|
+
try {
|
|
259
|
+
writeFileSync(tempPath, serialized, { mode: 0o600 });
|
|
260
|
+
renameSync(tempPath, statePath);
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
rmSync(tempPath, { force: true });
|
|
264
|
+
}
|
|
265
|
+
return snapshot(validated, fingerprint(Buffer.from(serialized, "utf8")));
|
|
266
|
+
}
|
|
267
|
+
workspaceStateDir(workspaceId) {
|
|
268
|
+
return join(this.stateDir, "workspaces", workspaceId);
|
|
269
|
+
}
|
|
270
|
+
statePath(workspaceId) {
|
|
271
|
+
return join(this.workspaceStateDir(workspaceId), "tasks.json");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function emptyState() {
|
|
275
|
+
return { version: TASK_STATE_VERSION, revision: 0, lists: [] };
|
|
276
|
+
}
|
|
277
|
+
function snapshot(state, stateFingerprint) {
|
|
278
|
+
return {
|
|
279
|
+
...cloneState(state),
|
|
280
|
+
fingerprint: stateFingerprint,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
function taskSummary(snapshotValue) {
|
|
284
|
+
return {
|
|
285
|
+
level: "summary",
|
|
286
|
+
version: snapshotValue.version,
|
|
287
|
+
revision: snapshotValue.revision,
|
|
288
|
+
fingerprint: snapshotValue.fingerprint,
|
|
289
|
+
lists: snapshotValue.lists.map((list) => ({
|
|
290
|
+
id: list.id,
|
|
291
|
+
name: list.name,
|
|
292
|
+
state: list.state,
|
|
293
|
+
revision: list.revision,
|
|
294
|
+
taskCount: list.tasks.length,
|
|
295
|
+
unfinishedTaskCount: list.tasks.filter((task) => task.status !== "completed").length,
|
|
296
|
+
})),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function taskHeaders(snapshotValue, listId) {
|
|
300
|
+
const lists = listId === undefined
|
|
301
|
+
? snapshotValue.lists
|
|
302
|
+
: [snapshotValue.lists[requireListIndex(snapshotValue, listId)]];
|
|
303
|
+
return {
|
|
304
|
+
level: "headers",
|
|
305
|
+
version: snapshotValue.version,
|
|
306
|
+
revision: snapshotValue.revision,
|
|
307
|
+
fingerprint: snapshotValue.fingerprint,
|
|
308
|
+
lists: lists.map((list) => ({
|
|
309
|
+
id: list.id,
|
|
310
|
+
name: list.name,
|
|
311
|
+
state: list.state,
|
|
312
|
+
revision: list.revision,
|
|
313
|
+
tasks: list.tasks.map((task) => ({
|
|
314
|
+
id: task.id,
|
|
315
|
+
status: task.status,
|
|
316
|
+
subject: task.subject,
|
|
317
|
+
})),
|
|
318
|
+
})),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
function taskDetail(snapshotValue, listId, taskId) {
|
|
322
|
+
const list = requireList(snapshotValue, listId);
|
|
323
|
+
const task = list.tasks[requireTaskIndex(list, taskId)];
|
|
324
|
+
return {
|
|
325
|
+
level: "detail",
|
|
326
|
+
version: snapshotValue.version,
|
|
327
|
+
revision: snapshotValue.revision,
|
|
328
|
+
fingerprint: snapshotValue.fingerprint,
|
|
329
|
+
list: {
|
|
330
|
+
id: list.id,
|
|
331
|
+
name: list.name,
|
|
332
|
+
state: list.state,
|
|
333
|
+
revision: list.revision,
|
|
334
|
+
},
|
|
335
|
+
task: { ...task },
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
function cloneState(state) {
|
|
339
|
+
return {
|
|
340
|
+
version: state.version,
|
|
341
|
+
revision: state.revision,
|
|
342
|
+
lists: state.lists.map((list) => ({
|
|
343
|
+
...list,
|
|
344
|
+
tasks: list.tasks.map((task) => ({ ...task })),
|
|
345
|
+
})),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function requireList(state, listId) {
|
|
349
|
+
return state.lists[requireListIndex(state, listId)];
|
|
350
|
+
}
|
|
351
|
+
function requireListIndex(state, listId) {
|
|
352
|
+
const index = state.lists.findIndex((list) => list.id === listId);
|
|
353
|
+
if (index < 0)
|
|
354
|
+
throw new Error(`Unknown Task List ${listId}.`);
|
|
355
|
+
return index;
|
|
356
|
+
}
|
|
357
|
+
function requireTaskIndex(list, taskId) {
|
|
358
|
+
const index = list.tasks.findIndex((task) => task.id === taskId);
|
|
359
|
+
if (index < 0)
|
|
360
|
+
throw new Error(`Task List ${list.id} has no Task ${taskId}.`);
|
|
361
|
+
return index;
|
|
362
|
+
}
|
|
363
|
+
function normalizeWorkspaceId(workspaceId) {
|
|
364
|
+
const value = workspaceId.trim();
|
|
365
|
+
if (!/^[a-z][a-z0-9_-]{1,127}$/.test(value)) {
|
|
366
|
+
throw new Error("Workspace ID is not valid for Workspace Task state.");
|
|
367
|
+
}
|
|
368
|
+
return value;
|
|
369
|
+
}
|
|
370
|
+
function normalizeListName(name) {
|
|
371
|
+
const value = name.trim();
|
|
372
|
+
if (!value)
|
|
373
|
+
throw new Error("Task List name must not be empty.");
|
|
374
|
+
if (value.length > MAX_LIST_NAME_LENGTH) {
|
|
375
|
+
throw new Error(`Task List name must be at most ${MAX_LIST_NAME_LENGTH} characters.`);
|
|
376
|
+
}
|
|
377
|
+
return value;
|
|
378
|
+
}
|
|
379
|
+
function normalizeTaskSubject(subject) {
|
|
380
|
+
const value = subject.trim();
|
|
381
|
+
if (!value)
|
|
382
|
+
throw new Error("Task subject must not be empty.");
|
|
383
|
+
if (value.length > MAX_TASK_SUBJECT_LENGTH) {
|
|
384
|
+
throw new Error(`Task subject must be at most ${MAX_TASK_SUBJECT_LENGTH} characters.`);
|
|
385
|
+
}
|
|
386
|
+
return value;
|
|
387
|
+
}
|
|
388
|
+
function normalizeTaskContent(content) {
|
|
389
|
+
if (content.length > MAX_TASK_CONTENT_LENGTH) {
|
|
390
|
+
throw new Error(`Task content must be at most ${MAX_TASK_CONTENT_LENGTH} characters.`);
|
|
391
|
+
}
|
|
392
|
+
return content;
|
|
393
|
+
}
|
|
394
|
+
function normalizeInsertPosition(position, length, label) {
|
|
395
|
+
if (position === undefined)
|
|
396
|
+
return length;
|
|
397
|
+
if (!Number.isInteger(position) || position < 0 || position > length) {
|
|
398
|
+
throw new Error(`${label} position must be an integer between 0 and ${length}.`);
|
|
399
|
+
}
|
|
400
|
+
return position;
|
|
401
|
+
}
|
|
402
|
+
function normalizeMovePosition(position, length, label) {
|
|
403
|
+
if (!Number.isInteger(position) || position < 0 || position >= length) {
|
|
404
|
+
throw new Error(`${label} position must be an integer between 0 and ${Math.max(0, length - 1)}.`);
|
|
405
|
+
}
|
|
406
|
+
return position;
|
|
407
|
+
}
|
|
408
|
+
function moveArrayEntry(values, from, to) {
|
|
409
|
+
const [value] = values.splice(from, 1);
|
|
410
|
+
values.splice(to, 0, value);
|
|
411
|
+
}
|
|
412
|
+
function fingerprint(content) {
|
|
413
|
+
return createHash("sha256").update(content).digest("hex");
|
|
414
|
+
}
|
|
415
|
+
function isErrno(error, code) {
|
|
416
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
417
|
+
}
|
|
418
|
+
function errorMessage(error) {
|
|
419
|
+
return error instanceof Error ? error.message : String(error);
|
|
420
|
+
}
|
package/dist/workspaces.js
CHANGED
|
@@ -46,6 +46,37 @@ export class WorkspaceRegistry {
|
|
|
46
46
|
}
|
|
47
47
|
return this.openReusableCheckout(workspaceInput.path, openOptions.conversationScopeId, bootstrapContext);
|
|
48
48
|
}
|
|
49
|
+
async inspectWorkspace(workspaceId) {
|
|
50
|
+
let session = this.store?.getSession(workspaceId);
|
|
51
|
+
if (!session) {
|
|
52
|
+
const workspace = this.workspaces.get(workspaceId);
|
|
53
|
+
if (workspace) {
|
|
54
|
+
session = {
|
|
55
|
+
id: workspace.id,
|
|
56
|
+
root: workspace.root,
|
|
57
|
+
status: "active",
|
|
58
|
+
mode: workspace.mode,
|
|
59
|
+
sourceRoot: workspace.sourceRoot,
|
|
60
|
+
baseRef: workspace.worktree?.baseRef,
|
|
61
|
+
baseSha: workspace.worktree?.baseSha,
|
|
62
|
+
branch: workspace.worktree?.branch,
|
|
63
|
+
targetBranch: workspace.worktree?.targetBranch,
|
|
64
|
+
managed: workspace.worktree?.managed ?? false,
|
|
65
|
+
createdAt: "",
|
|
66
|
+
lastUsedAt: "",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!session)
|
|
71
|
+
throw new Error(`Unknown workspaceId: ${workspaceId}.`);
|
|
72
|
+
const entry = await this.inventoryEntryForSession(session, Date.now(), false);
|
|
73
|
+
const { current: _current, ...inspection } = entry;
|
|
74
|
+
return {
|
|
75
|
+
kind: "workspace",
|
|
76
|
+
location: "local",
|
|
77
|
+
...inspection,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
49
80
|
async listWorkspaces(input = {}, openOptions = {}) {
|
|
50
81
|
this.pruneIdleWorkspaceSessions(openOptions.protectedWorkspaceIds ?? new Set());
|
|
51
82
|
const now = Date.now();
|
|
@@ -77,36 +108,7 @@ export class WorkspaceRegistry {
|
|
|
77
108
|
const rootKey = input.root
|
|
78
109
|
? await canonicalPath(assertAllowedPath(input.root, [...this.config.allowedRoots, this.config.worktreeRoot]))
|
|
79
110
|
: undefined;
|
|
80
|
-
const entries = await Promise.all(sessions.map(
|
|
81
|
-
const rootValid = await this.validSessionRoot(session) !== undefined;
|
|
82
|
-
const lastUsedAt = Date.parse(session.lastUsedAt);
|
|
83
|
-
const idleMs = Number.isFinite(lastUsedAt) ? Math.max(0, now - lastUsedAt) : 0;
|
|
84
|
-
const state = session.status !== "active"
|
|
85
|
-
? "closed"
|
|
86
|
-
: !rootValid
|
|
87
|
-
? "invalid"
|
|
88
|
-
: idleMs >= WORKSPACE_STALE_REMINDER_MS
|
|
89
|
-
? "stale"
|
|
90
|
-
: "active";
|
|
91
|
-
const projectRoot = session.sourceRoot ?? session.root;
|
|
92
|
-
return {
|
|
93
|
-
label: `${basename(resolve(projectRoot)) || "workspace"}/${session.id}`,
|
|
94
|
-
workspaceId: session.id,
|
|
95
|
-
root: session.root,
|
|
96
|
-
status: session.status,
|
|
97
|
-
state,
|
|
98
|
-
mode: session.mode,
|
|
99
|
-
sourceRoot: session.sourceRoot,
|
|
100
|
-
branch: session.branch,
|
|
101
|
-
targetBranch: session.targetBranch,
|
|
102
|
-
managed: session.managed,
|
|
103
|
-
createdAt: session.createdAt,
|
|
104
|
-
lastUsedAt: session.lastUsedAt,
|
|
105
|
-
idleMs,
|
|
106
|
-
rootValid,
|
|
107
|
-
current: currentWorkspaceIds.has(session.id),
|
|
108
|
-
};
|
|
109
|
-
}));
|
|
111
|
+
const entries = await Promise.all(sessions.map((session) => this.inventoryEntryForSession(session, now, currentWorkspaceIds.has(session.id))));
|
|
110
112
|
const filtered = [];
|
|
111
113
|
for (let index = 0; index < sessions.length; index += 1) {
|
|
112
114
|
const session = sessions[index];
|
|
@@ -151,6 +153,36 @@ export class WorkspaceRegistry {
|
|
|
151
153
|
},
|
|
152
154
|
};
|
|
153
155
|
}
|
|
156
|
+
async inventoryEntryForSession(session, now, current) {
|
|
157
|
+
const rootValid = await this.validSessionRoot(session) !== undefined;
|
|
158
|
+
const lastUsedAt = Date.parse(session.lastUsedAt);
|
|
159
|
+
const idleMs = Number.isFinite(lastUsedAt) ? Math.max(0, now - lastUsedAt) : 0;
|
|
160
|
+
const state = session.status !== "active"
|
|
161
|
+
? "closed"
|
|
162
|
+
: !rootValid
|
|
163
|
+
? "invalid"
|
|
164
|
+
: idleMs >= WORKSPACE_STALE_REMINDER_MS
|
|
165
|
+
? "stale"
|
|
166
|
+
: "active";
|
|
167
|
+
const projectRoot = session.sourceRoot ?? session.root;
|
|
168
|
+
return {
|
|
169
|
+
label: `${basename(resolve(projectRoot)) || "workspace"}/${session.id}`,
|
|
170
|
+
workspaceId: session.id,
|
|
171
|
+
root: session.root,
|
|
172
|
+
status: session.status,
|
|
173
|
+
state,
|
|
174
|
+
mode: session.mode,
|
|
175
|
+
sourceRoot: session.sourceRoot,
|
|
176
|
+
branch: session.branch,
|
|
177
|
+
targetBranch: session.targetBranch,
|
|
178
|
+
managed: session.managed,
|
|
179
|
+
createdAt: session.createdAt,
|
|
180
|
+
lastUsedAt: session.lastUsedAt,
|
|
181
|
+
idleMs,
|
|
182
|
+
rootValid,
|
|
183
|
+
current,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
154
186
|
async resumeWorkspace(workspaceId, conversationScopeId, bootstrapContext = "auto") {
|
|
155
187
|
const session = this.store?.getSession(workspaceId);
|
|
156
188
|
const context = session?.status === "closed" && session.mode === "worktree"
|
|
@@ -49,9 +49,9 @@ returning the full project context and does not record the current fingerprint a
|
|
|
49
49
|
already delivered. Context-delivery state remains conversation-scoped and does not
|
|
50
50
|
change the persistent Workspace identity.
|
|
51
51
|
|
|
52
|
-
Do not enumerate Workspace state on every normal open.
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
Do not enumerate Workspace state on every normal open. Use the same Core tool in
|
|
53
|
+
inventory mode only when the user wants to discover known Workspaces, continue earlier
|
|
54
|
+
work, or organize accumulated state:
|
|
55
55
|
|
|
56
56
|
```text
|
|
57
57
|
open_workspace(action="list")
|
|
@@ -66,6 +66,16 @@ root/source root, or stale-only state. Entries include a compact label such as
|
|
|
66
66
|
root validity, and whether that Workspace is currently selected by this
|
|
67
67
|
conversation. Listing is observational and does not refresh `lastUsedAt`.
|
|
68
68
|
|
|
69
|
+
When one known Workspace needs more bounded detail but must not be opened or resumed,
|
|
70
|
+
use `open_workspace(action="inspect", workspaceId="...")`. Inspection is a strict
|
|
71
|
+
allowlist projection: ordinary/worktree lifecycle metadata, Composite member
|
|
72
|
+
summaries, safe Relay presentation metadata, and an existing Task List summary may be
|
|
73
|
+
returned. It does not return bootstrap instructions, Skills, Capability guides,
|
|
74
|
+
Subagent bodies, files, process/Activity output, credentials, routes, or Task bodies;
|
|
75
|
+
it does not bind the conversation, mark bootstrap delivered, refresh `lastUsedAt`, or
|
|
76
|
+
grant execution authority. Explicitly open the target Workspace before mutating or
|
|
77
|
+
executing against it.
|
|
78
|
+
|
|
69
79
|
Treat persisted status and derived state separately. `status="active"` means the
|
|
70
80
|
record has not been explicitly closed. A valid recent record has `state="active"`;
|
|
71
81
|
a valid active record idle for more than two days has `state="stale"`; an active
|
package/docs/configuration.md
CHANGED
|
@@ -327,17 +327,52 @@ Hooks, Skills, language services, and Activity facts remain owned by the underly
|
|
|
327
327
|
Workspace. The Composite Activity Panel only aggregates their presentation into one
|
|
328
328
|
Host Turn.
|
|
329
329
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
`
|
|
336
|
-
`
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
330
|
+
## Workspace Tasks
|
|
331
|
+
|
|
332
|
+
`workspace.tasks` keeps lightweight Task Lists in ForgeRelay-owned private Workspace
|
|
333
|
+
state. Task data is not project bootstrap context and is not written into checkout or
|
|
334
|
+
managed-worktree Git contents. Reads use progressive disclosure: default `get` returns
|
|
335
|
+
List summaries and unfinished counts, `level="headers"` adds Task ID/status/subject,
|
|
336
|
+
and `level="detail"` returns full content for one explicitly selected Task.
|
|
337
|
+
|
|
338
|
+
ForgeRelay can append a bounded forgotten-update reminder after semantic Workspace
|
|
339
|
+
work when active Lists still contain unfinished Tasks. The counter defaults to 30
|
|
340
|
+
successful semantic work calls, resets on any Task mutation, treats one
|
|
341
|
+
`batch.execute` as one call, and does not count Workspace inventory/lifecycle UI,
|
|
342
|
+
Activity queries, Task reads, or Bash/process follow-up polling/input as new work.
|
|
343
|
+
The counter is process-local and may reset on server restart; Task state itself
|
|
344
|
+
remains durable.
|
|
345
|
+
|
|
346
|
+
| Variable | Default | Purpose |
|
|
347
|
+
| --- | --- | --- |
|
|
348
|
+
| `FORGERELAY_TASK_REMINDER_INTERVAL` | `30` | Successful semantic work calls between Task update reminders; `0` disables reminders. |
|
|
349
|
+
|
|
350
|
+
The same value may be persisted as `taskReminderInterval` in `config.json`. The
|
|
351
|
+
legacy-compatible `DEVSPACE_TASK_REMINDER_INTERVAL` environment name is also accepted.
|
|
352
|
+
|
|
353
|
+
Use `open_workspace(action="list")` only when the Agent needs lightweight inventory
|
|
354
|
+
to discover known Workspaces, continue earlier work, or organize Workspace state. The
|
|
355
|
+
inventory is paginated (50 records by default, at most 100) and can filter by Workspace
|
|
356
|
+
ID, persisted status, derived state, mode, canonical root/source root, or stale-only
|
|
357
|
+
state. Reading inventory does not refresh `lastUsedAt`. Persisted `status="active"`
|
|
358
|
+
means the record has not been explicitly closed; the derived `state` distinguishes
|
|
359
|
+
`active`, `stale`, `invalid`, and `closed`. A missing checkout or externally removed
|
|
360
|
+
managed-worktree root can therefore remain diagnostically `status="active"` while
|
|
361
|
+
appearing as `state="invalid"`. Canonical identity means ordinary same-target opens no
|
|
362
|
+
longer accumulate duplicate inventory rows; `action="list"` remains the formal
|
|
363
|
+
on-demand inventory path.
|
|
364
|
+
|
|
365
|
+
`open_workspace(action="inspect", workspaceId="...")` is the bounded read-only detail
|
|
366
|
+
path for one known Workspace. It uses an explicit allowlist and never opens/resumes the
|
|
367
|
+
target, changes conversation bindings or bootstrap-delivery records, refreshes
|
|
368
|
+
`lastUsedAt`, or grants file/process/Git/Capability authority. Safe projections include
|
|
369
|
+
ordinary/worktree lifecycle metadata, Composite member availability summaries, Relay
|
|
370
|
+
alias/execution-location presentation metadata, and an already-existing Task List
|
|
371
|
+
summary. Inspection never returns AGENTS/CLAUDE contents, Skills, Capability-guide
|
|
372
|
+
paths or contents, Subagent bodies/sessions, files, Git diffs, process/Activity output,
|
|
373
|
+
Hook/review artifacts, credentials, network/SSH routes, or Task bodies. Relay inspection
|
|
374
|
+
reports only that the Gateway route is known; it does not probe or claim the remote
|
|
375
|
+
Workspace lifecycle state.
|
|
341
376
|
|
|
342
377
|
For checkout-backed Workspaces, `close_workspace` defaults to `action="close"`:
|
|
343
378
|
it marks the persistent Workspace closed, removes current conversation bindings, and
|