@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.
@@ -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
+ }
@@ -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(async (session) => {
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. When the user wants to
53
- continue earlier work, inspect known Workspaces, or clean up accumulated state, use
54
- the same Core tool in inventory mode:
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
@@ -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
- Use `open_workspace(action="list")` only when the Agent needs to inspect known
331
- Workspaces, continue earlier work, or organize Workspace state. The inventory is
332
- paginated (50 records by default, at most 100) and can filter by Workspace ID,
333
- persisted status, derived state, mode, canonical root/source root, or stale-only
334
- state. Reading inventory does not refresh `lastUsedAt`. Persisted
335
- `status="active"` means the record has not been explicitly closed; the derived
336
- `state` distinguishes `active`, `stale`, `invalid`, and `closed`. A missing checkout
337
- or externally removed managed-worktree root can therefore remain diagnostically
338
- `status="active"` while appearing as `state="invalid"`. Canonical identity means
339
- ordinary same-target opens no longer accumulate duplicate inventory rows;
340
- `action="list"` remains the formal on-demand inventory path.
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