@deepseek-ai/dsh-workspace 0.1.6-alpha.2 → 0.1.7-alpha.1

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.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/workspace/workspace/README.md
5
- README.md: 8ac3bdbf0a69c031f1ddd5cdadb98f9cc4d55084
6
- README.zh.md: 225a24cc8fbedafe8e3ae6f64a849525eeb92067
5
+ README.md: 59b314a7302c5cf83314d08f6e8bdfe2dccba37f
6
+ README.zh.md: 78d563ee59048d5b1b6f976ac26f64907174645d
package/README.md CHANGED
@@ -33,7 +33,7 @@ Use it when the product shows a persistent workspace surface — a sidebar, sess
33
33
 
34
34
  ### Setting up
35
35
 
36
- The package takes no configuration of its own; it needs a session store, a session persistence backend, and the storage rows that keep its records. A minimal composition:
36
+ The package needs a session store, a session persistence backend, and the storage rows that keep its records. A minimal composition:
37
37
 
38
38
  ```yaml
39
39
  - name: '@deepseek-ai/dsh-session'
@@ -59,13 +59,22 @@ await project.setTitle('Renamed')
59
59
  ctx.workspaceRegistry.list() // shows the project, newest first
60
60
  ```
61
61
 
62
+ <a id="first-use-workspace"></a>
63
+ ### First-use Workspace
64
+
65
+ `initializeDefault(resolveDirectory)` initializes the default Workspace without creating a Session. Initial creation requires an empty Workspace registry and no live, persisted, or archived Session, including Sessions without a working directory. The registry checks persistent history directly; an empty visible sidebar is insufficient.
66
+
67
+ The directory resolver runs inside the mutation queue only when creation is eligible. It returns an absolute path and initial title; the registry creates missing parent directories, canonicalizes the path, rechecks Session history, and commits the Workspace with its initialization marker. An existing directory is reused; a file conflict or directory failure rejects initialization. The [Host controller](../../api/workspace-controller/README.md#first-use-workspace) supplies the Documents path policy.
68
+
69
+ The first successful registration records its identity durably. Repeated calls return it without resolving a directory again; renaming keeps that identity, and deleting its registration does not permit another automatic creation. Directory or registration failure leaves initialization unset for retry. Directories created before a later failure remain on disk. Once directory resolution succeeds, caller cancellation does not roll back directory creation or registration. The [first-use decision](../../../.agents/notes/implemented/feature/2026-09-20-default-workspace.md) explains this lifetime.
70
+
62
71
  ### Grouping sessions under a project
63
72
 
64
73
  A session joins the project of the directory it runs in: create a session in a project's directory and it appears under that project, newest first. A session can only belong to one project. A session whose directory cannot be validated — no recorded directory, or a moved or deleted folder — cannot join and stays ungrouped.
65
74
 
66
75
  ### Hiding and restoring sessions, and removing projects
67
76
 
68
- Hide a session from the grouping when it should stop appearing there: it disappears from the visible list, while its session, history, and place in the project stay intact. Restore a hidden session when it should appear again: it returns to its recorded position under its project, or to the ungrouped sessions when it belongs to none. Remove a project when it is no longer needed: it leaves the list, and its folder, files, and session histories are never touched — those sessions become ungrouped. Adding the same directory again afterwards starts a fresh project without the old sessions.
77
+ Hide a session from the grouping when it should stop appearing there: it disappears from the visible list, while its session, history, and place in the project stay intact. A session with running work — its own turn, a running subagent, a background job, or an active reminder — is not hidden underneath that work: the registry refuses with the list of what runs, and a caller that asks to stop the work first has it stopped the way the user's own stop actions do, then hidden. Restore a hidden session when it should appear again: it returns to its recorded position under its project, or to the ungrouped sessions when it belongs to none, and continues the conversation from a regularly ended log. Remove a project when it is no longer needed: it leaves the list, and its folder, files, and session histories are never touched — those sessions become ungrouped. Adding the same directory again afterwards starts a fresh project without the old sessions.
69
78
 
70
79
  -----
71
80
 
@@ -87,7 +96,9 @@ This section explains the design decisions behind the feature and points at the
87
96
 
88
97
  ### API behavior
89
98
 
90
- The API is one small family with two owners: `WorkspaceRegistry` creates, orders, and deletes projects, manages their session accounting, and archives or restores single sessions; the `Workspace` entity exposes the display title, directory status, and the session projection. Per-method contracts live in the code, not this README — see [src/index.ts](src/index.ts) and [src/entity.ts](src/entity.ts).
99
+ The API has two owners: `WorkspaceRegistry` creates, orders, and deletes projects, manages their Session accounting, and pins, unpins, archives, or restores Sessions; the `Workspace` entity exposes the display title, directory status, and Session projection. Pinning requires a known, unarchived Session; archiving clears its pin in the same durable write, and restoring does not restore that pin. Per-method contracts live in [src/index.ts](src/index.ts) and [src/entity.ts](src/entity.ts).
100
+
101
+ Archive admission is a capability seam over two Host events this package declares and dispatches: `workspace/session-activity` (waterfall) asks the composed providers what still runs for a Session, and `workspace/session-stop` (parallel) asks them to stop it. `archiveSession(sessionId)` asks the activity waterfall once and rejects a non-empty answer with `WorkspaceActiveSessionError`, whose `activity` lists each family with its items — the keys are the providers' own, merged into `SessionActivityKindMap`, which this package leaves empty; `archiveSession(sessionId, { stopActivity: true })` skips the activity check, writes the archive, and then dispatches the stop event, so the durable archive set already gates every wake the stops induce; a rejecting provider is logged and the archive stays. The call resolves once every provider's stop request was issued, while the stopped work settles on its own. Both questions come after the existence check and never for an already archived id. The shipped providers are the Agent registry (the running turn), the job registry seam (owned jobs), the Subagent runtime (running descendants), and the Schedule plugin (active reminders); a composition without providers archives freely.
91
102
 
92
103
  ### Source map
93
104
 
@@ -102,7 +113,7 @@ The API is one small family with two owners: `WorkspaceRegistry` creates, orders
102
113
 
103
114
  ### Durable shape
104
115
 
105
- The registry opens the `workspace` domain (version 2): a `workspaces` table keyed by `WorkspaceId` plus one global state holding `workspaceIds` (the authoritative display order), `archivedSessionIds`, and the optional `pendingMutation` marker. Records written before `archivedSessionIds` existed parse with an empty set through the schema default. Archiving and unarchiving both rewrite only that global state, so a restore is one filtered write of the same field; unarchive runs no session-existence probe, because dropping an id from the set cannot introduce an unknown one, while archive verifies the session before adding it.
116
+ The registry opens the `workspace` domain (version 2): a `workspaces` table keyed by `WorkspaceId` plus one global state holding `workspaceIds` (the authoritative display order), `archivedSessionIds`, `pinnedSessionIds`, the optional `defaultWorkspaceId` first-use identity, and the optional `pendingMutation` marker. Archive and pin sets contain Session id strings, default to empty, and carry no per-entry objects or timestamps; the pin array keeps the most recently pinned id first. Archiving clears the pin in the same global-state write without changing Workspace membership. Unarchive runs no session-existence probe, because dropping an id from the set cannot introduce an unknown one, while archive verifies the session before adding it.
106
117
 
107
118
  ### Lifecycle
108
119
 
@@ -161,6 +172,7 @@ These limits define when the project list is a poor fit or needs special operati
161
172
  - **A session joins only with a recorded directory** — a session belongs to a project only when its record carries a directory that resolves to the project's path; sessions without one stay ungrouped, and a session from another directory cannot be moved in.
162
173
  - **External changes are seen late** — if another process deletes or damages a directory, the project reflects it only at the next refresh or restart.
163
174
  - **Archive and unarchive enforce different session checks** — a restore only drops an id from the archive set, so an entry whose session is gone still unarchives and leaves no unknown referent; a restore of an id that is not archived resolves without writing, while `archiveSession` rejects a session that is neither live nor persisted.
175
+ - **The activity check and the archive write are not one atomic step** — a turn that starts between the providers' answer and the durable write is hidden while running, and every model step whose `agent/pre-step` precedes the write still runs with its tool calls; the API Session Controller's gate ends the first step proposed after the write as `blocked`, so the exposure is bounded by that write's latency, in practice one model step.
164
176
  - **Re-adding a directory starts fresh** — after removal, adding the same directory again creates a new project with an empty session list; the old sessions do not come back automatically.
165
177
 
166
178
  <a id="dev-note"></a>
package/README.zh.md CHANGED
@@ -33,7 +33,7 @@ kind: "package-reference"
33
33
 
34
34
  ### 设置
35
35
 
36
- 此包本身不声明任何配置;它需要会话存储、会话持久化后端,以及保存其记录的存储行。最小组合如下:
36
+ 此包需要会话存储、会话持久化后端,以及保存其记录的存储行。最小组合如下:
37
37
 
38
38
  ```yaml
39
39
  - name: '@deepseek-ai/dsh-session'
@@ -59,13 +59,22 @@ await project.setTitle('Renamed')
59
59
  ctx.workspaceRegistry.list() // shows the project, newest first
60
60
  ```
61
61
 
62
+ <a id="first-use-workspace"></a>
63
+ ### 首次使用工作区
64
+
65
+ `initializeDefault(resolveDirectory)` 初始化默认 Workspace,不创建 Session。首次创建要求 Workspace 注册表为空,且不存在运行时、持久化或已归档 Session,包括没有工作目录的 Session。注册表直接检查持久化历史;仅凭可见侧边栏为空不足以判断。
66
+
67
+ 目录解析器仅在允许创建时于变更队列内运行。它返回绝对路径和初始标题;注册表创建缺失的父目录、规范化路径、重新检查 Session 历史,再一起提交 Workspace 和初始化标记。已存在的目录直接复用;文件冲突或目录操作失败时拒绝初始化。[Host 控制器](../../api/workspace-controller/README.zh.md#first-use-workspace)提供 Documents 路径策略。
68
+
69
+ 首次成功登记会持久保存工作区身份。重复调用直接返回它,不再解析目录;改名保留该身份,删除登记也不会允许再次自动创建。目录或登记失败时,初始化状态保持未设置,可以重试。后续步骤失败前已创建的目录会保留在磁盘上。目录解析成功后,调用方取消操作不会回滚目录创建或登记。[首次使用决策](../../../.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md)说明这一生命周期。
70
+
62
71
  ### 将会话归入项目
63
72
 
64
73
  会话加入它运行目录所在的项目:在项目目录中创建会话,它就会出现在该项目下,新到旧排列。一个会话只能属于一个项目。目录无法校验的会话——没有记录目录,或目录被移动、删除——无法加入,保持 Ungrouped。
65
74
 
66
75
  ### 隐藏、恢复会话与移除项目
67
76
 
68
- 当会话不应再出现在分组中时隐藏它:它会从可见列表中消失,但其会话、历史与在项目中的位置都保持不变。当被隐藏的会话应重新出现时恢复它:它会回到其项目下记录的位置;不属于任何项目时则回到 Ungrouped。项目不再需要时移除它:它离开列表,而其文件夹、文件与会话历史绝不受影响——这些会话变成 Ungrouped。之后再次添加同一目录会从空项目开始,不会带回旧会话。
77
+ 当会话不应再出现在分组中时隐藏它:它会从可见列表中消失,但其会话、历史与在项目中的位置都保持不变。仍有工作在跑的会话——它自己的回合、运行中的子代理、后台任务或活跃提醒——不会被藏在这些工作之下:注册表会拒绝并列出还在跑的内容;调用方若要求先停止这些工作,注册表会按用户自己的停止操作同样的方式停掉它们,然后再隐藏。当被隐藏的会话应重新出现时恢复它:它会回到其项目下记录的位置;不属于任何项目时则回到 Ungrouped,并从一份正常收尾的日志继续对话。项目不再需要时移除它:它离开列表,而其文件夹、文件与会话历史绝不受影响——这些会话变成 Ungrouped。之后再次添加同一目录会从空项目开始,不会带回旧会话。
69
78
 
70
79
  -----
71
80
 
@@ -85,9 +94,12 @@ ctx.workspaceRegistry.list() // shows the project, newest first
85
94
  - **两次写入的变更带显式标记。** 创建与删除在记录/顺序对可能分叉之前先持久化 `pendingMutation` 标记,因此启动只补全被中断的操作,未标记的分叉作为损坏明确报错。
86
95
  - **串行化写入。** 注册表操作跑在同一条操作链上;实体变更通过领域写链上的 `table.update` 执行,写入 `updatedAt`,并在其所在的链位置决定成员资格。
87
96
 
97
+ <a id="api-behavior"></a>
88
98
  ### API 行为
89
99
 
90
- 该 API 是一个由两个所有者构成的小家族:`WorkspaceRegistry` 负责创建、排序与删除项目、管理其会话记账,以及归档或恢复单个会话;`Workspace` 实体暴露显示标题、目录状态与会话投影。各方法的精确约定在代码中,而非本 README——参见 [src/index.ts](src/index.ts) 与 [src/entity.ts](src/entity.ts)。
100
+ 该 API 由两个对象负责:`WorkspaceRegistry` 创建、排序与删除项目,管理会话记账,并置顶、取消置顶、归档或恢复会话;`Workspace` 实体暴露显示标题、目录状态与会话投影。置顶要求会话已知且未归档;归档在同一次持久化写入中清除置顶,恢复会话不会恢复置顶。各方法的精确约定见 [src/index.ts](src/index.ts) 与 [src/entity.ts](src/entity.ts)。
101
+
102
+ 归档准入是本包声明并派发的两个宿主事件之上的能力接缝:`workspace/session-activity`(waterfall)向已组合的提供方询问某会话还有什么在跑,`workspace/session-stop`(parallel)请它们停止这些工作。`archiveSession(sessionId)` 只询问一次活动 waterfall,对非空答案以 `WorkspaceActiveSessionError` 拒绝,其 `activity` 按族列出各项——键由各提供方自己合并进本包留空的 `SessionActivityKindMap`;`archiveSession(sessionId, { stopActivity: true })` 跳过活动检查,先写入归档,再派发停止事件,因此持久化的归档集合已经拦住停止所引发的每一次唤醒;提供方抛错只记日志,归档保留。调用在每个提供方的停止请求都已发出后返回,被停止的工作自行收敛。两种询问都在存在性检查之后进行,对已归档 id 从不发生。随附的提供方是 Agent 注册表(运行中的回合)、任务注册表接缝(所属任务)、Subagent runtime(运行中的子孙)与 Schedule 插件(活跃提醒);没有提供方的组合可自由归档。
91
103
 
92
104
  ### 源码地图
93
105
 
@@ -102,7 +114,7 @@ ctx.workspaceRegistry.list() // shows the project, newest first
102
114
 
103
115
  ### 持久形态
104
116
 
105
- 注册表打开 `workspace` 领域(版本 2):一张以 `WorkspaceId` 为键的 `workspaces` 表,加上一个持有 `workspaceIds`(权威显示顺序)、`archivedSessionIds` 与可选 `pendingMutation` 标记的全局状态。在 `archivedSessionIds` 存在之前写入的记录会通过 schema 默认值解析为空集合。归档与取消归档都只重写该全局状态,因此恢复就是对同一字段的一次过滤写入;取消归档不做会话存在性探测,因为从集合中移除 id 不可能引入未知 id,而归档会在加入前校验会话。
117
+ 注册表打开 `workspace` 领域(版本 2):一张以 `WorkspaceId` 为键的 `workspaces` 表,加上一个持有 `workspaceIds`(权威显示顺序)、`archivedSessionIds`、`pinnedSessionIds`、可选的首次使用身份 `defaultWorkspaceId` 与可选 `pendingMutation` 标记的全局状态。归档与置顶集合存储会话 id 字符串,默认值为空,不包含逐项对象或时间戳;置顶数组把最近置顶的 id 放在前面。归档在同一次全局状态写入中清除置顶,但不改变 Workspace 成员关系。取消归档不做会话存在性探测,因为从集合中移除 id 不可能引入未知 id,而归档会在加入前校验会话。
106
118
 
107
119
  ### 生命周期
108
120
 
@@ -161,6 +173,7 @@ ctx.workspaceRegistry.list() // shows the project, newest first
161
173
  - **只有带记录目录的会话才能加入**——只有记录中带有可解析为项目路径的目录的会话才属于项目;没有目录的会话保持 Ungrouped,来自其他目录的会话无法移入。
162
174
  - **外部变更延迟可见**——如果另一进程删除或损坏目录,项目只能在下次刷新或重启后反映出来。
163
175
  - **归档与取消归档执行不同的会话校验**——恢复只是从归档集合中移除 id,因此会话已不存在的条目仍能取消归档,也不会留下未知引用;对未归档 id 执行恢复不写盘即完成,而 `archiveSession` 会拒绝既非实时也未持久化的会话。
176
+ - **活动检查与归档写入不是一个原子步骤**——在提供方作答与持久化写入之间开始的回合会在隐藏状态下运行,`agent/pre-step` 先于该写入的每个模型步连同其工具调用照常执行;API Session Controller 的门禁把写入之后提出的第一步以 `blocked` 收口,因此暴露面以该写入的时延为界,实际上是一个模型步。
164
177
  - **重新添加目录从空开始**——移除后再次添加同一目录会创建空会话列表的新项目;旧会话不会自动回来。
165
178
 
166
179
  <a id="dev-note"></a>
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { realpath, stat } from "node:fs/promises";
2
+ import { mkdir, realpath, stat } from "node:fs/promises";
3
3
  import { Service } from "@deepseek-ai/cordis";
4
4
  import { posix, win32 } from "node:path";
5
5
  import { z } from "zod";
@@ -200,6 +200,7 @@ var WorkspaceEntity = class {
200
200
  */
201
201
  /** Workspace id schema at the durable boundary; branding has no runtime representation. */
202
202
  const workspaceId = z.string().transform((value) => value);
203
+ const sessionId = z.string().transform((value) => brandString(value));
203
204
  /**
204
205
  * Durable shape of one workspace record. `path` is the `fs.realpath` canon
205
206
  * stamped at create; `sessionIds` is the ordered ownership account (array
@@ -208,7 +209,7 @@ const workspaceId = z.string().transform((value) => value);
208
209
  const workspaceRecord = z.object({
209
210
  path: z.string(),
210
211
  title: z.string(),
211
- sessionIds: z.array(z.string().transform((value) => brandString(value))),
212
+ sessionIds: z.array(sessionId),
212
213
  createdAt: z.string(),
213
214
  updatedAt: z.string()
214
215
  });
@@ -231,12 +232,18 @@ const workspacePendingMutation = z.discriminatedUnion("operation", [z.object({
231
232
  * the registry-global archive set layered over workspace accounting: an
232
233
  * archived session keeps its `sessionIds` slot (unarchiving must restore the
233
234
  * position), so the set never participates in the one-owner accounting
234
- * invariant. Defaulted so records written before the field parse unchanged.
235
+ * invariant. `pinnedSessionIds` is the registry-global pin set in pin order
236
+ * (most recently pinned first); pinning and archival are mutually
237
+ * exclusive, so archiving drops the session's pin. Both session sets are
238
+ * defaulted so records written before the fields parse unchanged.
235
239
  */
236
240
  const workspaceDomainState = z.object({
237
241
  initialized: z.boolean(),
242
+ /** First-use Workspace identity, retained after its registration is deleted. */
243
+ defaultWorkspaceId: workspaceId.optional(),
238
244
  workspaceIds: z.array(workspaceId),
239
- archivedSessionIds: z.array(z.string().transform((value) => brandString(value))).default([]),
245
+ archivedSessionIds: z.array(sessionId).default([]),
246
+ pinnedSessionIds: z.array(sessionId).default([]),
240
247
  pendingMutation: workspacePendingMutation.optional()
241
248
  });
242
249
  /**
@@ -253,7 +260,8 @@ const workspaceDomainSpec = defineDomain({
253
260
  initial: {
254
261
  initialized: false,
255
262
  workspaceIds: [],
256
- archivedSessionIds: []
263
+ archivedSessionIds: [],
264
+ pinnedSessionIds: []
257
265
  }
258
266
  },
259
267
  tables: { workspaces: domainTable(workspaceRecord) }
@@ -275,20 +283,53 @@ function WorkspaceId(id) {
275
283
  return id;
276
284
  }
277
285
  /**
278
- * An archiveSession request named a session neither live nor in session
279
- * persistence — a definite miss only; storage faults propagate as themselves.
286
+ * An archiveSession or pinSession request named a session neither live nor in
287
+ * session persistence — a definite miss only; storage faults propagate as
288
+ * themselves.
280
289
  */
281
290
  var WorkspaceUnknownSessionError = class extends Error {
282
291
  sessionId;
283
292
  /**
284
293
  * @param sessionId - The unknown session id.
294
+ * @param verb - The registry operation that named the session.
285
295
  */
286
- constructor(sessionId) {
287
- super(`cannot archive session '${sessionId}': live sessions and session persistence hold no such session`);
296
+ constructor(sessionId, verb) {
297
+ super(`cannot ${verb} session '${sessionId}': live sessions and session persistence hold no such session`);
288
298
  this.sessionId = sessionId;
289
299
  this.name = "WorkspaceUnknownSessionError";
290
300
  }
291
301
  };
302
+ /**
303
+ * An archiveSession request named a session that at least one
304
+ * `workspace/session-activity` listener reported active. Nothing was written;
305
+ * `activity` names what must stop before the session can be archived.
306
+ */
307
+ var WorkspaceActiveSessionError = class extends Error {
308
+ sessionId;
309
+ activity;
310
+ /**
311
+ * @param sessionId - The active session id.
312
+ * @param activity - The reported activity, in listener order.
313
+ */
314
+ constructor(sessionId, activity) {
315
+ super(`cannot archive session '${sessionId}': the session is active (${activity.map((entry) => entry.kind).join(", ")})`);
316
+ this.sessionId = sessionId;
317
+ this.activity = activity;
318
+ this.name = "WorkspaceActiveSessionError";
319
+ }
320
+ };
321
+ /** A pinSession request named a session currently in the archive set; pinning and archival are mutually exclusive. */
322
+ var WorkspaceArchivedSessionPinError = class extends Error {
323
+ sessionId;
324
+ /**
325
+ * @param sessionId - The archived session id.
326
+ */
327
+ constructor(sessionId) {
328
+ super(`cannot pin session '${sessionId}': the session is archived`);
329
+ this.sessionId = sessionId;
330
+ this.name = "WorkspaceArchivedSessionPinError";
331
+ }
332
+ };
292
333
  /** A workspace reorder named a source or anchor absent from the durable registry order. */
293
334
  var WorkspaceOrderInvalidError = class extends Error {
294
335
  workspaceId;
@@ -368,6 +409,31 @@ var WorkspaceRegistry = class extends Service {
368
409
  return await this.enqueueOperation(() => this.createCanonical(canonical, title));
369
410
  }
370
411
  /**
412
+ * Initialize the default Workspace only while both the registry and Session
413
+ * history are empty. Repeated requests reuse its durable identity; deleting
414
+ * that registration permanently disables automatic creation.
415
+ * @param resolveDirectory - resolve the absolute directory and initial title;
416
+ * called only for eligible creation, inside the registry mutation queue.
417
+ * Missing directories are created recursively before registration.
418
+ * After resolution, caller cancellation does not roll back creation or registration.
419
+ * @returns the initialized Workspace, or undefined when automatic creation is ineligible.
420
+ */
421
+ initializeDefault(resolveDirectory) {
422
+ return this.enqueueOperation(async () => {
423
+ const state = this.requireState();
424
+ if (state.defaultWorkspaceId !== void 0) return this.entities.get(state.defaultWorkspaceId);
425
+ const sessions = this.ctx.get("sessions");
426
+ if (sessions === void 0) throw new Error("default Workspace initialization requires the Session store");
427
+ if (state.workspaceIds.length > 0 || state.archivedSessionIds.length > 0 || sessions.list().length > 0 || (await this.listStoredHeaders()).length > 0) return void 0;
428
+ const { path, title } = await resolveDirectory();
429
+ if (!fullyQualifiedWorkspacePath(path)) throw new TypeError(`Workspace path is not fully qualified: '${path}'`);
430
+ await mkdir(path, { recursive: true });
431
+ const canonical = await realpathNormalize(path);
432
+ if ((await this.listStoredHeaders()).length > 0 || sessions.list().length > 0) return void 0;
433
+ return this.createCanonical(canonical, title, true);
434
+ });
435
+ }
436
+ /**
371
437
  * Look up a workspace by id.
372
438
  * @param id - Workspace id.
373
439
  * @returns the workspace, or `undefined` when unknown.
@@ -439,19 +505,35 @@ var WorkspaceRegistry = class extends Service {
439
505
  /**
440
506
  * Archive one session durably. The session must exist (live or in session
441
507
  * persistence); its workspace accounting — or lack of one — is irrelevant.
442
- * An already archived id resolves without writing.
508
+ * Without `stopActivity` the session must also be inactive: the
509
+ * `workspace/session-activity` waterfall is asked once, and any reported
510
+ * activity rejects with {@link WorkspaceActiveSessionError} before anything
511
+ * is written. With `stopActivity` the archive is written without an
512
+ * activity check, and the `workspace/session-stop` providers are then asked
513
+ * to stop the session's work: the durable archive set is what a provider's
514
+ * `agent/pre-step` gate reads, so every wake the stops induce is already
515
+ * blocked. Archiving drops the session's pin in the same durable write
516
+ * (pinning and archival are mutually exclusive). An already archived id
517
+ * resolves without writing, asking, or stopping.
443
518
  * @param sessionId - The session to archive.
444
- * @returns resolution after durability.
519
+ * @param options - Whether running work is stopped instead of refusing.
520
+ * @returns resolution after durability and, with `stopActivity`, after every stop request was issued.
445
521
  */
446
- archiveSession(sessionId) {
522
+ archiveSession(sessionId, options = {}) {
447
523
  return this.enqueueOperation(async () => {
448
524
  if (this.requireState().archivedSessionIds.includes(sessionId)) return;
449
- if (!await this.sessionKnown(sessionId)) throw new WorkspaceUnknownSessionError(sessionId);
525
+ if (!await this.sessionKnown(sessionId)) throw new WorkspaceUnknownSessionError(sessionId, "archive");
526
+ if (options.stopActivity !== true) {
527
+ const activity = await this.ctx.waterfall("workspace/session-activity", { sessionId }, () => Promise.resolve([]));
528
+ if (activity.length > 0) throw new WorkspaceActiveSessionError(sessionId, activity);
529
+ }
450
530
  const state = this.requireState();
451
531
  await this.setState({
452
532
  ...state,
453
- archivedSessionIds: [...state.archivedSessionIds, sessionId]
533
+ archivedSessionIds: [...state.archivedSessionIds, sessionId],
534
+ pinnedSessionIds: state.pinnedSessionIds.filter((id) => id !== sessionId)
454
535
  });
536
+ if (options.stopActivity === true) await this.stopSessionActivity(sessionId);
455
537
  });
456
538
  }
457
539
  /**
@@ -475,6 +557,51 @@ var WorkspaceRegistry = class extends Service {
475
557
  });
476
558
  }
477
559
  /**
560
+ * The registry-global pin set: sessions surfaced ahead of every unpinned
561
+ * session on grouping surfaces. Pinning never touches workspace accounting.
562
+ * @returns Session ids in pin order (most recently pinned first).
563
+ */
564
+ get pinnedSessionIds() {
565
+ return this.requireState().pinnedSessionIds;
566
+ }
567
+ /**
568
+ * Pin one session durably, prepending it to the registry-global pin set.
569
+ * The session must exist (live or in session persistence) and must not be
570
+ * archived. An already pinned id resolves without writing or reordering.
571
+ * @param sessionId - The session to pin.
572
+ * @returns resolution after durability.
573
+ */
574
+ pinSession(sessionId) {
575
+ return this.enqueueOperation(async () => {
576
+ if (this.requireState().pinnedSessionIds.includes(sessionId)) return;
577
+ if (this.requireState().archivedSessionIds.includes(sessionId)) throw new WorkspaceArchivedSessionPinError(sessionId);
578
+ if (!await this.sessionKnown(sessionId)) throw new WorkspaceUnknownSessionError(sessionId, "pin");
579
+ const state = this.requireState();
580
+ await this.setState({
581
+ ...state,
582
+ pinnedSessionIds: [sessionId, ...state.pinnedSessionIds]
583
+ });
584
+ });
585
+ }
586
+ /**
587
+ * Unpin one session durably by dropping it from the registry-global pin
588
+ * set. Unpinning runs no session-existence check because removing an id
589
+ * cannot introduce an unknown one, so an entry whose session is gone still
590
+ * resolves. An id that is not pinned resolves without writing.
591
+ * @param sessionId - The session to unpin.
592
+ * @returns resolution after durability.
593
+ */
594
+ unpinSession(sessionId) {
595
+ return this.enqueueOperation(async () => {
596
+ const state = this.requireState();
597
+ if (!state.pinnedSessionIds.includes(sessionId)) return;
598
+ await this.setState({
599
+ ...state,
600
+ pinnedSessionIds: state.pinnedSessionIds.filter((id) => id !== sessionId)
601
+ });
602
+ });
603
+ }
604
+ /**
478
605
  * Whether a session is live, header-indexed, or present in a fresh
479
606
  * persistence listing. Only a definite miss returns false — a failing
480
607
  * `sessionPersistence.list()` propagates so storage faults never
@@ -486,6 +613,16 @@ var WorkspaceRegistry = class extends Service {
486
613
  await this.indexHeaders(await this.listStoredHeaders());
487
614
  return this.headers.has(id);
488
615
  }
616
+ /** Request every provider's stop; a failing provider is logged, never a reason to keep the session visible. */
617
+ async stopSessionActivity(sessionId) {
618
+ try {
619
+ await this.ctx.parallel("workspace/session-stop", { sessionId });
620
+ } catch (error) {
621
+ /* v8 ignore next -- the plain arm guards a rethrowing dispatcher. */
622
+ const failures = error instanceof AggregateError ? error.errors : [error];
623
+ for (const failure of failures) this.ctx.logger.warn(`workspace: stopping session '${sessionId}' for archive failed: ${String(failure)}`);
624
+ }
625
+ }
489
626
  /**
490
627
  * Resolve by canonical directory path without creating or mutating a
491
628
  * workspace. A missing path rejects during `realpath`; an existing unowned
@@ -497,7 +634,7 @@ var WorkspaceRegistry = class extends Service {
497
634
  const canonical = await realpathNormalize(path);
498
635
  for (const entity of this.entities.values()) if (entity.path === canonical) return entity;
499
636
  }
500
- async createCanonical(canonical, title) {
637
+ async createCanonical(canonical, title, firstUse = false) {
501
638
  for (const entity of this.entities.values()) if (entity.path === canonical) return entity;
502
639
  const workspaceName = title ?? defaultWorkspaceTitle(canonical);
503
640
  const table = this.requireTable();
@@ -539,9 +676,11 @@ var WorkspaceRegistry = class extends Service {
539
676
  }
540
677
  try {
541
678
  await this.setState({
679
+ ...state,
680
+ pendingMutation: void 0,
542
681
  initialized: true,
543
- workspaceIds: [id, ...state.workspaceIds],
544
- archivedSessionIds: state.archivedSessionIds
682
+ ...firstUse ? { defaultWorkspaceId: id } : {},
683
+ workspaceIds: [id, ...state.workspaceIds]
545
684
  });
546
685
  } catch (error) {
547
686
  this.entities.delete(id);
@@ -564,9 +703,10 @@ var WorkspaceRegistry = class extends Service {
564
703
  if (entity === void 0) return false;
565
704
  const state = this.requireState();
566
705
  const nextState = {
706
+ ...state,
707
+ pendingMutation: void 0,
567
708
  initialized: true,
568
- workspaceIds: state.workspaceIds.filter((workspaceId) => workspaceId !== id),
569
- archivedSessionIds: state.archivedSessionIds
709
+ workspaceIds: state.workspaceIds.filter((workspaceId) => workspaceId !== id)
570
710
  };
571
711
  await this.setState({
572
712
  ...nextState,
@@ -607,9 +747,8 @@ var WorkspaceRegistry = class extends Service {
607
747
  if (state.workspaceIds.includes(pending.workspaceId)) throw new Error(`workspace domain is inconsistent: pending ${pending.operation} workspace '${pending.workspaceId}' is still present in registry order`);
608
748
  await this.requireTable().delete(pending.workspaceId);
609
749
  await this.setState({
610
- initialized: state.initialized,
611
- workspaceIds: state.workspaceIds,
612
- archivedSessionIds: state.archivedSessionIds
750
+ ...state,
751
+ pendingMutation: void 0
613
752
  });
614
753
  }
615
754
  async bootstrap(headers) {
@@ -677,12 +816,14 @@ var WorkspaceRegistry = class extends Service {
677
816
  if (!sameIds(state.workspaceIds, workspaceIds)) await this.setState({
678
817
  initialized: false,
679
818
  workspaceIds,
680
- archivedSessionIds: state.archivedSessionIds
819
+ archivedSessionIds: state.archivedSessionIds,
820
+ pinnedSessionIds: state.pinnedSessionIds
681
821
  });
682
822
  await this.setState({
683
823
  initialized: true,
684
824
  workspaceIds,
685
- archivedSessionIds: state.archivedSessionIds
825
+ archivedSessionIds: state.archivedSessionIds,
826
+ pinnedSessionIds: state.pinnedSessionIds
686
827
  });
687
828
  }
688
829
  validateStoredState(state) {
@@ -802,4 +943,4 @@ var WorkspaceRegistry = class extends Service {
802
943
  };
803
944
  const sameSessionIds = (left, right) => left.length === right.length && left.every((id, index) => id === right[index]);
804
945
  //#endregion
805
- export { WorkspaceId, WorkspaceMoveInvalidError, WorkspaceOrderInvalidError, WorkspaceRegistry, WorkspaceRegistry as default, WorkspaceUnknownSessionError, realpathNormalize, workspaceDomainSpec, workspaceDomainState, workspaceRecord };
946
+ export { WorkspaceActiveSessionError, WorkspaceArchivedSessionPinError, WorkspaceId, WorkspaceMoveInvalidError, WorkspaceOrderInvalidError, WorkspaceRegistry, WorkspaceRegistry as default, WorkspaceUnknownSessionError, realpathNormalize, workspaceDomainSpec, workspaceDomainState, workspaceRecord };
package/lib/invariant.js CHANGED
@@ -15,6 +15,7 @@ import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
15
15
  */
16
16
  /** Workspace id schema at the durable boundary; branding has no runtime representation. */
17
17
  const workspaceId = z.string().transform((value) => value);
18
+ const sessionId = z.string().transform((value) => brandString(value));
18
19
  /**
19
20
  * Durable shape of one workspace record. `path` is the `fs.realpath` canon
20
21
  * stamped at create; `sessionIds` is the ordered ownership account (array
@@ -23,7 +24,7 @@ const workspaceId = z.string().transform((value) => value);
23
24
  const workspaceRecord = z.object({
24
25
  path: z.string(),
25
26
  title: z.string(),
26
- sessionIds: z.array(z.string().transform((value) => brandString(value))),
27
+ sessionIds: z.array(sessionId),
27
28
  createdAt: z.string(),
28
29
  updatedAt: z.string()
29
30
  });
@@ -45,14 +46,18 @@ defineDomain({
45
46
  global: {
46
47
  schema: z.object({
47
48
  initialized: z.boolean(),
49
+ /** First-use Workspace identity, retained after its registration is deleted. */
50
+ defaultWorkspaceId: workspaceId.optional(),
48
51
  workspaceIds: z.array(workspaceId),
49
- archivedSessionIds: z.array(z.string().transform((value) => brandString(value))).default([]),
52
+ archivedSessionIds: z.array(sessionId).default([]),
53
+ pinnedSessionIds: z.array(sessionId).default([]),
50
54
  pendingMutation: workspacePendingMutation.optional()
51
55
  }),
52
56
  initial: {
53
57
  initialized: false,
54
58
  workspaceIds: [],
55
- archivedSessionIds: []
59
+ archivedSessionIds: [],
60
+ pinnedSessionIds: []
56
61
  }
57
62
  },
58
63
  tables: { workspaces: domainTable(workspaceRecord) }
@@ -7,8 +7,8 @@
7
7
  import { Context, Service } from '@deepseek-ai/cordis';
8
8
  import type { SessionId } from '@deepseek-ai/dsh-session';
9
9
  export { WorkspaceMoveInvalidError } from './entity.ts';
10
- import type { Workspace, WorkspaceId as WorkspaceIdBrand } from './types.ts';
11
- export type { Workspace } from './types.ts';
10
+ import type { SessionActivity, Workspace, WorkspaceId as WorkspaceIdBrand } from './types.ts';
11
+ export type { SessionActivity, SessionActivityItem, SessionActivityKind, SessionActivityKindMap, Workspace, } from './types.ts';
12
12
  export { workspaceDomainState, workspaceRecord, workspaceDomainSpec } from './spec.ts';
13
13
  export type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts';
14
14
  export { realpathNormalize } from './paths.ts';
@@ -21,13 +21,37 @@ export type WorkspaceId = WorkspaceIdBrand;
21
21
  */
22
22
  export declare function WorkspaceId(id: string): WorkspaceId;
23
23
  /**
24
- * An archiveSession request named a session neither live nor in session
25
- * persistence — a definite miss only; storage faults propagate as themselves.
24
+ * An archiveSession or pinSession request named a session neither live nor in
25
+ * session persistence — a definite miss only; storage faults propagate as
26
+ * themselves.
26
27
  */
27
28
  export declare class WorkspaceUnknownSessionError extends Error {
28
29
  readonly sessionId: SessionId;
29
30
  /**
30
31
  * @param sessionId - The unknown session id.
32
+ * @param verb - The registry operation that named the session.
33
+ */
34
+ constructor(sessionId: SessionId, verb: 'archive' | 'pin');
35
+ }
36
+ /**
37
+ * An archiveSession request named a session that at least one
38
+ * `workspace/session-activity` listener reported active. Nothing was written;
39
+ * `activity` names what must stop before the session can be archived.
40
+ */
41
+ export declare class WorkspaceActiveSessionError extends Error {
42
+ readonly sessionId: SessionId;
43
+ readonly activity: readonly SessionActivity[];
44
+ /**
45
+ * @param sessionId - The active session id.
46
+ * @param activity - The reported activity, in listener order.
47
+ */
48
+ constructor(sessionId: SessionId, activity: readonly SessionActivity[]);
49
+ }
50
+ /** A pinSession request named a session currently in the archive set; pinning and archival are mutually exclusive. */
51
+ export declare class WorkspaceArchivedSessionPinError extends Error {
52
+ readonly sessionId: SessionId;
53
+ /**
54
+ * @param sessionId - The archived session id.
31
55
  */
32
56
  constructor(sessionId: SessionId);
33
57
  }
@@ -39,10 +63,52 @@ export declare class WorkspaceOrderInvalidError extends Error {
39
63
  */
40
64
  constructor(workspaceId: WorkspaceId);
41
65
  }
66
+ /** The session an archive request is about to write into the archive set. */
67
+ export interface SessionActivityRequest {
68
+ readonly sessionId: SessionId;
69
+ }
70
+ /** Caller choices for {@link WorkspaceRegistry.archiveSession}. */
71
+ export interface ArchiveSessionOptions {
72
+ /**
73
+ * Ask the composed providers to stop the session's running work instead of
74
+ * refusing the archive because of it. The archive is written first, then
75
+ * the stops are requested; running work is never awaited to settlement, and
76
+ * a provider failure is logged without undoing the archive.
77
+ */
78
+ readonly stopActivity?: boolean;
79
+ }
42
80
  declare module '@deepseek-ai/cordis' {
43
81
  interface Context {
44
82
  workspaceRegistry: WorkspaceRegistry;
45
83
  }
84
+ interface Events {
85
+ /**
86
+ * Ask the composed providers what still runs for a session before it is
87
+ * archived. A listener prepends its own {@link SessionActivity} entries to
88
+ * the result of `next()`; the registry's innermost callback returns an
89
+ * empty list, so a composition without providers archives freely. Any
90
+ * non-empty result refuses the archive without a write.
91
+ * @param request - the session about to be archived.
92
+ * @param next - delegate to the remaining providers.
93
+ * @mode waterfall
94
+ */
95
+ 'workspace/session-activity'(request: SessionActivityRequest, next: () => Promise<readonly SessionActivity[]>): Promise<readonly SessionActivity[]>;
96
+ /**
97
+ * Stop a session's running work because the caller archived it with
98
+ * `stopActivity`; the archive set is durable when this dispatches. Each
99
+ * provider stops its own families — cancelling a turn, its subagent
100
+ * descendants, owned jobs, or active schedules — through the same cancel
101
+ * paths the user's own stop actions use, so the session log ends every
102
+ * open turn regularly and a later unarchive can continue the
103
+ * conversation. Listeners issue their stop requests without waiting for
104
+ * running work to settle; a listener may await its own durability
105
+ * barrier. A rejection is logged by the registry and does not undo the
106
+ * archive.
107
+ * @param request - the session being archived.
108
+ * @mode parallel
109
+ */
110
+ 'workspace/session-stop'(request: SessionActivityRequest): Promise<void> | void;
111
+ }
46
112
  }
47
113
  /**
48
114
  * Durable workspace registry. Startup waits for `sessionPersistence`, builds
@@ -77,6 +143,20 @@ export declare class WorkspaceRegistry extends Service {
77
143
  * @returns the existing or newly durable workspace.
78
144
  */
79
145
  create(path: string, title?: string): Promise<Workspace>;
146
+ /**
147
+ * Initialize the default Workspace only while both the registry and Session
148
+ * history are empty. Repeated requests reuse its durable identity; deleting
149
+ * that registration permanently disables automatic creation.
150
+ * @param resolveDirectory - resolve the absolute directory and initial title;
151
+ * called only for eligible creation, inside the registry mutation queue.
152
+ * Missing directories are created recursively before registration.
153
+ * After resolution, caller cancellation does not roll back creation or registration.
154
+ * @returns the initialized Workspace, or undefined when automatic creation is ineligible.
155
+ */
156
+ initializeDefault(resolveDirectory: () => Promise<{
157
+ path: string;
158
+ title: string;
159
+ }>): Promise<Workspace | undefined>;
80
160
  /**
81
161
  * Look up a workspace by id.
82
162
  * @param id - Workspace id.
@@ -117,11 +197,21 @@ export declare class WorkspaceRegistry extends Service {
117
197
  /**
118
198
  * Archive one session durably. The session must exist (live or in session
119
199
  * persistence); its workspace accounting — or lack of one — is irrelevant.
120
- * An already archived id resolves without writing.
200
+ * Without `stopActivity` the session must also be inactive: the
201
+ * `workspace/session-activity` waterfall is asked once, and any reported
202
+ * activity rejects with {@link WorkspaceActiveSessionError} before anything
203
+ * is written. With `stopActivity` the archive is written without an
204
+ * activity check, and the `workspace/session-stop` providers are then asked
205
+ * to stop the session's work: the durable archive set is what a provider's
206
+ * `agent/pre-step` gate reads, so every wake the stops induce is already
207
+ * blocked. Archiving drops the session's pin in the same durable write
208
+ * (pinning and archival are mutually exclusive). An already archived id
209
+ * resolves without writing, asking, or stopping.
121
210
  * @param sessionId - The session to archive.
122
- * @returns resolution after durability.
211
+ * @param options - Whether running work is stopped instead of refusing.
212
+ * @returns resolution after durability and, with `stopActivity`, after every stop request was issued.
123
213
  */
124
- archiveSession(sessionId: SessionId): Promise<void>;
214
+ archiveSession(sessionId: SessionId, options?: ArchiveSessionOptions): Promise<void>;
125
215
  /**
126
216
  * Unarchive one session durably by dropping it from the registry-global
127
217
  * archive set; the accounting slot was never touched, so the session
@@ -133,6 +223,29 @@ export declare class WorkspaceRegistry extends Service {
133
223
  * @returns resolution after durability.
134
224
  */
135
225
  unarchiveSession(sessionId: SessionId): Promise<void>;
226
+ /**
227
+ * The registry-global pin set: sessions surfaced ahead of every unpinned
228
+ * session on grouping surfaces. Pinning never touches workspace accounting.
229
+ * @returns Session ids in pin order (most recently pinned first).
230
+ */
231
+ get pinnedSessionIds(): readonly SessionId[];
232
+ /**
233
+ * Pin one session durably, prepending it to the registry-global pin set.
234
+ * The session must exist (live or in session persistence) and must not be
235
+ * archived. An already pinned id resolves without writing or reordering.
236
+ * @param sessionId - The session to pin.
237
+ * @returns resolution after durability.
238
+ */
239
+ pinSession(sessionId: SessionId): Promise<void>;
240
+ /**
241
+ * Unpin one session durably by dropping it from the registry-global pin
242
+ * set. Unpinning runs no session-existence check because removing an id
243
+ * cannot introduce an unknown one, so an entry whose session is gone still
244
+ * resolves. An id that is not pinned resolves without writing.
245
+ * @param sessionId - The session to unpin.
246
+ * @returns resolution after durability.
247
+ */
248
+ unpinSession(sessionId: SessionId): Promise<void>;
136
249
  /**
137
250
  * Whether a session is live, header-indexed, or present in a fresh
138
251
  * persistence listing. Only a definite miss returns false — a failing
@@ -140,6 +253,8 @@ export declare class WorkspaceRegistry extends Service {
140
253
  * masquerade as an unknown session.
141
254
  */
142
255
  private sessionKnown;
256
+ /** Request every provider's stop; a failing provider is logged, never a reason to keep the session visible. */
257
+ private stopSessionActivity;
143
258
  /**
144
259
  * Resolve by canonical directory path without creating or mutating a
145
260
  * workspace. A missing path rejects during `realpath`; an existing unowned
@@ -5,11 +5,11 @@
5
5
  * @module @deepseek-ai/dsh-workspace
6
6
  */
7
7
  import { randomUUID } from 'node:crypto';
8
- import { stat } from 'node:fs/promises';
8
+ import { mkdir, stat } from 'node:fs/promises';
9
9
  import { Service } from '@deepseek-ai/cordis';
10
10
  import { WorkspaceEntity } from "./entity.js";
11
11
  export { WorkspaceMoveInvalidError } from "./entity.js";
12
- import { defaultWorkspaceTitle, realpathNormalize } from "./paths.js";
12
+ import { defaultWorkspaceTitle, fullyQualifiedWorkspacePath, realpathNormalize } from "./paths.js";
13
13
  import { workspaceDomainSpec } from "./spec.js";
14
14
  export { workspaceDomainState, workspaceRecord, workspaceDomainSpec } from "./spec.js";
15
15
  export { realpathNormalize } from "./paths.js";
@@ -22,20 +22,53 @@ export function WorkspaceId(id) {
22
22
  return id;
23
23
  }
24
24
  /**
25
- * An archiveSession request named a session neither live nor in session
26
- * persistence — a definite miss only; storage faults propagate as themselves.
25
+ * An archiveSession or pinSession request named a session neither live nor in
26
+ * session persistence — a definite miss only; storage faults propagate as
27
+ * themselves.
27
28
  */
28
29
  export class WorkspaceUnknownSessionError extends Error {
29
30
  sessionId;
30
31
  /**
31
32
  * @param sessionId - The unknown session id.
33
+ * @param verb - The registry operation that named the session.
32
34
  */
33
- constructor(sessionId) {
34
- super(`cannot archive session '${sessionId}': live sessions and session persistence hold no such session`);
35
+ constructor(sessionId, verb) {
36
+ super(`cannot ${verb} session '${sessionId}': live sessions and session persistence hold no such session`);
35
37
  this.sessionId = sessionId;
36
38
  this.name = 'WorkspaceUnknownSessionError';
37
39
  }
38
40
  }
41
+ /**
42
+ * An archiveSession request named a session that at least one
43
+ * `workspace/session-activity` listener reported active. Nothing was written;
44
+ * `activity` names what must stop before the session can be archived.
45
+ */
46
+ export class WorkspaceActiveSessionError extends Error {
47
+ sessionId;
48
+ activity;
49
+ /**
50
+ * @param sessionId - The active session id.
51
+ * @param activity - The reported activity, in listener order.
52
+ */
53
+ constructor(sessionId, activity) {
54
+ super(`cannot archive session '${sessionId}': the session is active (${activity.map(entry => entry.kind).join(', ')})`);
55
+ this.sessionId = sessionId;
56
+ this.activity = activity;
57
+ this.name = 'WorkspaceActiveSessionError';
58
+ }
59
+ }
60
+ /** A pinSession request named a session currently in the archive set; pinning and archival are mutually exclusive. */
61
+ export class WorkspaceArchivedSessionPinError extends Error {
62
+ sessionId;
63
+ /**
64
+ * @param sessionId - The archived session id.
65
+ */
66
+ constructor(sessionId) {
67
+ super(`cannot pin session '${sessionId}': the session is archived`);
68
+ this.sessionId = sessionId;
69
+ this.name = 'WorkspaceArchivedSessionPinError';
70
+ }
71
+ }
39
72
  /** A workspace reorder named a source or anchor absent from the durable registry order. */
40
73
  export class WorkspaceOrderInvalidError extends Error {
41
74
  workspaceId;
@@ -124,6 +157,38 @@ export class WorkspaceRegistry extends Service {
124
157
  }
125
158
  return await this.enqueueOperation(() => this.createCanonical(canonical, title));
126
159
  }
160
+ /**
161
+ * Initialize the default Workspace only while both the registry and Session
162
+ * history are empty. Repeated requests reuse its durable identity; deleting
163
+ * that registration permanently disables automatic creation.
164
+ * @param resolveDirectory - resolve the absolute directory and initial title;
165
+ * called only for eligible creation, inside the registry mutation queue.
166
+ * Missing directories are created recursively before registration.
167
+ * After resolution, caller cancellation does not roll back creation or registration.
168
+ * @returns the initialized Workspace, or undefined when automatic creation is ineligible.
169
+ */
170
+ initializeDefault(resolveDirectory) {
171
+ return this.enqueueOperation(async () => {
172
+ const state = this.requireState();
173
+ if (state.defaultWorkspaceId !== undefined)
174
+ return this.entities.get(state.defaultWorkspaceId);
175
+ const sessions = this.ctx.get('sessions');
176
+ if (sessions === undefined)
177
+ throw new Error('default Workspace initialization requires the Session store');
178
+ if (state.workspaceIds.length > 0 || state.archivedSessionIds.length > 0
179
+ || sessions.list().length > 0 || (await this.listStoredHeaders()).length > 0)
180
+ return undefined;
181
+ const { path, title } = await resolveDirectory();
182
+ if (!fullyQualifiedWorkspacePath(path))
183
+ throw new TypeError(`Workspace path is not fully qualified: '${path}'`);
184
+ await mkdir(path, { recursive: true });
185
+ const canonical = await realpathNormalize(path);
186
+ // A Session can start outside the registry queue while directory preparation awaits I/O.
187
+ if ((await this.listStoredHeaders()).length > 0 || sessions.list().length > 0)
188
+ return undefined;
189
+ return this.createCanonical(canonical, title, true);
190
+ });
191
+ }
127
192
  /**
128
193
  * Look up a workspace by id.
129
194
  * @param id - Workspace id.
@@ -196,21 +261,42 @@ export class WorkspaceRegistry extends Service {
196
261
  /**
197
262
  * Archive one session durably. The session must exist (live or in session
198
263
  * persistence); its workspace accounting — or lack of one — is irrelevant.
199
- * An already archived id resolves without writing.
264
+ * Without `stopActivity` the session must also be inactive: the
265
+ * `workspace/session-activity` waterfall is asked once, and any reported
266
+ * activity rejects with {@link WorkspaceActiveSessionError} before anything
267
+ * is written. With `stopActivity` the archive is written without an
268
+ * activity check, and the `workspace/session-stop` providers are then asked
269
+ * to stop the session's work: the durable archive set is what a provider's
270
+ * `agent/pre-step` gate reads, so every wake the stops induce is already
271
+ * blocked. Archiving drops the session's pin in the same durable write
272
+ * (pinning and archival are mutually exclusive). An already archived id
273
+ * resolves without writing, asking, or stopping.
200
274
  * @param sessionId - The session to archive.
201
- * @returns resolution after durability.
275
+ * @param options - Whether running work is stopped instead of refusing.
276
+ * @returns resolution after durability and, with `stopActivity`, after every stop request was issued.
202
277
  */
203
- archiveSession(sessionId) {
278
+ archiveSession(sessionId, options = {}) {
204
279
  return this.enqueueOperation(async () => {
205
280
  // The chain slot serializes against every other registry write, so this
206
281
  // check-then-write pair cannot interleave with another archive.
207
282
  if (this.requireState().archivedSessionIds.includes(sessionId))
208
283
  return;
209
284
  if (!(await this.sessionKnown(sessionId))) {
210
- throw new WorkspaceUnknownSessionError(sessionId);
285
+ throw new WorkspaceUnknownSessionError(sessionId, 'archive');
286
+ }
287
+ if (options.stopActivity !== true) {
288
+ const activity = await this.ctx.waterfall('workspace/session-activity', { sessionId }, () => Promise.resolve([]));
289
+ if (activity.length > 0)
290
+ throw new WorkspaceActiveSessionError(sessionId, activity);
211
291
  }
212
292
  const state = this.requireState();
213
- await this.setState({ ...state, archivedSessionIds: [...state.archivedSessionIds, sessionId] });
293
+ await this.setState({
294
+ ...state,
295
+ archivedSessionIds: [...state.archivedSessionIds, sessionId],
296
+ pinnedSessionIds: state.pinnedSessionIds.filter(id => id !== sessionId),
297
+ });
298
+ if (options.stopActivity === true)
299
+ await this.stopSessionActivity(sessionId);
214
300
  });
215
301
  }
216
302
  /**
@@ -236,6 +322,61 @@ export class WorkspaceRegistry extends Service {
236
322
  });
237
323
  });
238
324
  }
325
+ /**
326
+ * The registry-global pin set: sessions surfaced ahead of every unpinned
327
+ * session on grouping surfaces. Pinning never touches workspace accounting.
328
+ * @returns Session ids in pin order (most recently pinned first).
329
+ */
330
+ get pinnedSessionIds() {
331
+ return this.requireState().pinnedSessionIds;
332
+ }
333
+ /**
334
+ * Pin one session durably, prepending it to the registry-global pin set.
335
+ * The session must exist (live or in session persistence) and must not be
336
+ * archived. An already pinned id resolves without writing or reordering.
337
+ * @param sessionId - The session to pin.
338
+ * @returns resolution after durability.
339
+ */
340
+ pinSession(sessionId) {
341
+ return this.enqueueOperation(async () => {
342
+ // The chain slot serializes against every other registry write, so this
343
+ // check-then-write pair cannot interleave with another pin or archive.
344
+ if (this.requireState().pinnedSessionIds.includes(sessionId))
345
+ return;
346
+ if (this.requireState().archivedSessionIds.includes(sessionId)) {
347
+ throw new WorkspaceArchivedSessionPinError(sessionId);
348
+ }
349
+ if (!(await this.sessionKnown(sessionId))) {
350
+ throw new WorkspaceUnknownSessionError(sessionId, 'pin');
351
+ }
352
+ const state = this.requireState();
353
+ await this.setState({
354
+ ...state,
355
+ pinnedSessionIds: [sessionId, ...state.pinnedSessionIds],
356
+ });
357
+ });
358
+ }
359
+ /**
360
+ * Unpin one session durably by dropping it from the registry-global pin
361
+ * set. Unpinning runs no session-existence check because removing an id
362
+ * cannot introduce an unknown one, so an entry whose session is gone still
363
+ * resolves. An id that is not pinned resolves without writing.
364
+ * @param sessionId - The session to unpin.
365
+ * @returns resolution after durability.
366
+ */
367
+ unpinSession(sessionId) {
368
+ return this.enqueueOperation(async () => {
369
+ // The chain slot serializes against every other registry write, so this
370
+ // check-then-write pair cannot interleave with a concurrent pin.
371
+ const state = this.requireState();
372
+ if (!state.pinnedSessionIds.includes(sessionId))
373
+ return;
374
+ await this.setState({
375
+ ...state,
376
+ pinnedSessionIds: state.pinnedSessionIds.filter(id => id !== sessionId),
377
+ });
378
+ });
379
+ }
239
380
  /**
240
381
  * Whether a session is live, header-indexed, or present in a fresh
241
382
  * persistence listing. Only a definite miss returns false — a failing
@@ -250,6 +391,20 @@ export class WorkspaceRegistry extends Service {
250
391
  await this.indexHeaders(await this.listStoredHeaders());
251
392
  return this.headers.has(id);
252
393
  }
394
+ /** Request every provider's stop; a failing provider is logged, never a reason to keep the session visible. */
395
+ async stopSessionActivity(sessionId) {
396
+ try {
397
+ await this.ctx.parallel('workspace/session-stop', { sessionId });
398
+ }
399
+ catch (error) {
400
+ // ctx.parallel settles every listener and rejects with one AggregateError.
401
+ /* v8 ignore next -- the plain arm guards a rethrowing dispatcher. */
402
+ const failures = error instanceof AggregateError ? error.errors : [error];
403
+ for (const failure of failures) {
404
+ this.ctx.logger.warn(`workspace: stopping session '${sessionId}' for archive failed: ${String(failure)}`);
405
+ }
406
+ }
407
+ }
253
408
  /**
254
409
  * Resolve by canonical directory path without creating or mutating a
255
410
  * workspace. A missing path rejects during `realpath`; an existing unowned
@@ -265,7 +420,7 @@ export class WorkspaceRegistry extends Service {
265
420
  }
266
421
  return undefined;
267
422
  }
268
- async createCanonical(canonical, title) {
423
+ async createCanonical(canonical, title, firstUse = false) {
269
424
  for (const entity of this.entities.values()) {
270
425
  if (entity.path === canonical)
271
426
  return entity;
@@ -310,9 +465,11 @@ export class WorkspaceRegistry extends Service {
310
465
  }
311
466
  try {
312
467
  await this.setState({
468
+ ...state,
469
+ pendingMutation: undefined,
313
470
  initialized: true,
471
+ ...(firstUse ? { defaultWorkspaceId: id } : {}),
314
472
  workspaceIds: [id, ...state.workspaceIds],
315
- archivedSessionIds: state.archivedSessionIds,
316
473
  });
317
474
  }
318
475
  catch (error) {
@@ -339,9 +496,10 @@ export class WorkspaceRegistry extends Service {
339
496
  return false;
340
497
  const state = this.requireState();
341
498
  const nextState = {
499
+ ...state,
500
+ pendingMutation: undefined,
342
501
  initialized: true,
343
502
  workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id),
344
- archivedSessionIds: state.archivedSessionIds,
345
503
  };
346
504
  await this.setState({
347
505
  ...nextState,
@@ -391,11 +549,7 @@ export class WorkspaceRegistry extends Service {
391
549
  + `'${pending.workspaceId}' is still present in registry order`);
392
550
  }
393
551
  await this.requireTable().delete(pending.workspaceId);
394
- await this.setState({
395
- initialized: state.initialized,
396
- workspaceIds: state.workspaceIds,
397
- archivedSessionIds: state.archivedSessionIds,
398
- });
552
+ await this.setState({ ...state, pendingMutation: undefined });
399
553
  }
400
554
  async bootstrap(headers) {
401
555
  const table = this.requireTable();
@@ -478,9 +632,19 @@ export class WorkspaceRegistry extends Service {
478
632
  })
479
633
  .map(([id]) => id);
480
634
  if (!sameIds(state.workspaceIds, workspaceIds)) {
481
- await this.setState({ initialized: false, workspaceIds, archivedSessionIds: state.archivedSessionIds });
635
+ await this.setState({
636
+ initialized: false,
637
+ workspaceIds,
638
+ archivedSessionIds: state.archivedSessionIds,
639
+ pinnedSessionIds: state.pinnedSessionIds,
640
+ });
482
641
  }
483
- await this.setState({ initialized: true, workspaceIds, archivedSessionIds: state.archivedSessionIds });
642
+ await this.setState({
643
+ initialized: true,
644
+ workspaceIds,
645
+ archivedSessionIds: state.archivedSessionIds,
646
+ pinnedSessionIds: state.pinnedSessionIds,
647
+ });
484
648
  }
485
649
  validateStoredState(state) {
486
650
  const table = this.requireTable();
@@ -28,12 +28,17 @@ export type WorkspaceRecord = z.infer<typeof workspaceRecord>;
28
28
  * the registry-global archive set layered over workspace accounting: an
29
29
  * archived session keeps its `sessionIds` slot (unarchiving must restore the
30
30
  * position), so the set never participates in the one-owner accounting
31
- * invariant. Defaulted so records written before the field parse unchanged.
31
+ * invariant. `pinnedSessionIds` is the registry-global pin set in pin order
32
+ * (most recently pinned first); pinning and archival are mutually
33
+ * exclusive, so archiving drops the session's pin. Both session sets are
34
+ * defaulted so records written before the fields parse unchanged.
32
35
  */
33
36
  export declare const workspaceDomainState: z.ZodObject<{
34
37
  initialized: z.ZodBoolean;
38
+ defaultWorkspaceId: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<WorkspaceId, string>>>;
35
39
  workspaceIds: z.ZodArray<z.ZodPipe<z.ZodString, z.ZodTransform<WorkspaceId, string>>>;
36
40
  archivedSessionIds: z.ZodDefault<z.ZodArray<z.ZodPipe<z.ZodString, z.ZodTransform<SessionId, string>>>>;
41
+ pinnedSessionIds: z.ZodDefault<z.ZodArray<z.ZodPipe<z.ZodString, z.ZodTransform<SessionId, string>>>>;
37
42
  pendingMutation: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
38
43
  operation: z.ZodLiteral<"create">;
39
44
  workspaceId: z.ZodPipe<z.ZodString, z.ZodTransform<WorkspaceId, string>>;
@@ -56,8 +61,10 @@ export declare const workspaceDomainSpec: {
56
61
  global: {
57
62
  schema: z.ZodObject<{
58
63
  initialized: z.ZodBoolean;
64
+ defaultWorkspaceId: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<WorkspaceId, string>>>;
59
65
  workspaceIds: z.ZodArray<z.ZodPipe<z.ZodString, z.ZodTransform<WorkspaceId, string>>>;
60
66
  archivedSessionIds: z.ZodDefault<z.ZodArray<z.ZodPipe<z.ZodString, z.ZodTransform<SessionId, string>>>>;
67
+ pinnedSessionIds: z.ZodDefault<z.ZodArray<z.ZodPipe<z.ZodString, z.ZodTransform<SessionId, string>>>>;
61
68
  pendingMutation: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
62
69
  operation: z.ZodLiteral<"create">;
63
70
  workspaceId: z.ZodPipe<z.ZodString, z.ZodTransform<WorkspaceId, string>>;
@@ -70,6 +77,7 @@ export declare const workspaceDomainSpec: {
70
77
  initialized: boolean;
71
78
  workspaceIds: never[];
72
79
  archivedSessionIds: never[];
80
+ pinnedSessionIds: never[];
73
81
  };
74
82
  };
75
83
  tables: {
package/lib/types/spec.js CHANGED
@@ -9,6 +9,7 @@ import { brandString } from '@deepseek-ai/dsh-brand';
9
9
  import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain';
10
10
  /** Workspace id schema at the durable boundary; branding has no runtime representation. */
11
11
  const workspaceId = z.string().transform(value => value);
12
+ const sessionId = z.string().transform(value => brandString(value));
12
13
  /**
13
14
  * Durable shape of one workspace record. `path` is the `fs.realpath` canon
14
15
  * stamped at create; `sessionIds` is the ordered ownership account (array
@@ -17,7 +18,7 @@ const workspaceId = z.string().transform(value => value);
17
18
  export const workspaceRecord = z.object({
18
19
  path: z.string(),
19
20
  title: z.string(),
20
- sessionIds: z.array(z.string().transform(value => brandString(value))),
21
+ sessionIds: z.array(sessionId),
21
22
  createdAt: z.string(),
22
23
  updatedAt: z.string(),
23
24
  });
@@ -37,12 +38,18 @@ const workspacePendingMutation = z.discriminatedUnion('operation', [
37
38
  * the registry-global archive set layered over workspace accounting: an
38
39
  * archived session keeps its `sessionIds` slot (unarchiving must restore the
39
40
  * position), so the set never participates in the one-owner accounting
40
- * invariant. Defaulted so records written before the field parse unchanged.
41
+ * invariant. `pinnedSessionIds` is the registry-global pin set in pin order
42
+ * (most recently pinned first); pinning and archival are mutually
43
+ * exclusive, so archiving drops the session's pin. Both session sets are
44
+ * defaulted so records written before the fields parse unchanged.
41
45
  */
42
46
  export const workspaceDomainState = z.object({
43
47
  initialized: z.boolean(),
48
+ /** First-use Workspace identity, retained after its registration is deleted. */
49
+ defaultWorkspaceId: workspaceId.optional(),
44
50
  workspaceIds: z.array(workspaceId),
45
- archivedSessionIds: z.array(z.string().transform(value => brandString(value))).default([]),
51
+ archivedSessionIds: z.array(sessionId).default([]),
52
+ pinnedSessionIds: z.array(sessionId).default([]),
46
53
  pendingMutation: workspacePendingMutation.optional(),
47
54
  });
48
55
  /**
@@ -56,7 +63,7 @@ export const workspaceDomainSpec = defineDomain({
56
63
  version: 2,
57
64
  global: {
58
65
  schema: workspaceDomainState,
59
- initial: { initialized: false, workspaceIds: [], archivedSessionIds: [] },
66
+ initial: { initialized: false, workspaceIds: [], archivedSessionIds: [], pinnedSessionIds: [] },
60
67
  },
61
68
  tables: { workspaces: domainTable(workspaceRecord) },
62
69
  });
@@ -19,6 +19,35 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
19
19
  };
20
20
  }
21
21
  }
22
+ /**
23
+ * Activity families a `workspace/session-activity` listener may report. This
24
+ * package declares none: each provider merges its own key from a module both
25
+ * its Host and Client faces import, so a consumer that renders the families
26
+ * sees exactly the keys its program compiled and falls through to a generic
27
+ * description for any other. The shipped providers merge `turn` (the Agent
28
+ * registry), `job` (the job registry seam), `subagent` (the Subagent
29
+ * runtime), and `schedule` (the Schedule plugin).
30
+ */
31
+ export interface SessionActivityKindMap {
32
+ }
33
+ /** One activity family key. */
34
+ export type SessionActivityKind = keyof SessionActivityKindMap;
35
+ /** One active item of a family that has per-item identity. */
36
+ export interface SessionActivityItem {
37
+ /** Family-specific identity: a session id, a job id, or a schedule id. */
38
+ readonly id: string;
39
+ /** Display label when the family carries one (a job label, a subagent label). */
40
+ readonly label?: string;
41
+ }
42
+ /**
43
+ * One reason a session counts as active for archive admission. Families with
44
+ * per-item identity list their items so a caller can name what must stop.
45
+ */
46
+ export interface SessionActivity {
47
+ readonly kind: SessionActivityKind;
48
+ /** Active items of the family; absent for a family without per-item identity (`turn`). */
49
+ readonly items?: readonly SessionActivityItem[];
50
+ }
22
51
  /**
23
52
  * One workspace: a stable id over an existing directory, a display title, and
24
53
  * an ordered candidate account of sessions. Membership requires both an id in
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-workspace",
3
3
  "description": "Workspace entity registry (ctx.workspaceRegistry): durable workspace records with validated session attachment over the domain data form for the DeepSeek Harness",
4
- "version": "0.1.6-alpha.2",
4
+ "version": "0.1.7-alpha.1",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -37,25 +37,25 @@
37
37
  ],
38
38
  "license": "MIT",
39
39
  "peerDependencies": {
40
- "@deepseek-ai/cordis": "^4.0.2",
41
- "@deepseek-ai/dsh-invariants": "^0.1.6-alpha.2",
42
- "@deepseek-ai/dsh-storage": "^0.1.6-alpha.2",
43
- "@deepseek-ai/dsh-storage-domain": "^0.1.6-alpha.2",
44
- "@deepseek-ai/dsh-session": "^0.1.6-alpha.2",
45
- "@deepseek-ai/dsh-session-persistence": "^0.1.6-alpha.2",
46
- "@deepseek-ai/dsh-typert-protocol": "^0.1.6-alpha.2"
40
+ "@deepseek-ai/cordis": "^4.0.3",
41
+ "@deepseek-ai/dsh-invariants": "^0.1.7-alpha.1",
42
+ "@deepseek-ai/dsh-session": "^0.1.7-alpha.1",
43
+ "@deepseek-ai/dsh-session-persistence": "^0.1.7-alpha.1",
44
+ "@deepseek-ai/dsh-storage": "^0.1.7-alpha.1",
45
+ "@deepseek-ai/dsh-storage-domain": "^0.1.7-alpha.1",
46
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.7-alpha.1"
47
47
  },
48
48
  "dependencies": {
49
49
  "zod": "^4.4.3",
50
- "@deepseek-ai/dsh-brand": "^0.1.6-alpha.2"
50
+ "@deepseek-ai/dsh-brand": "^0.1.7-alpha.1"
51
51
  },
52
52
  "devDependencies": {
53
- "@deepseek-ai/cordis": "^4.0.2",
54
- "@deepseek-ai/dsh-invariants": "^0.1.6-alpha.2",
55
- "@deepseek-ai/dsh-session": "^0.1.6-alpha.2",
56
- "@deepseek-ai/dsh-session-persistence": "^0.1.6-alpha.2",
57
- "@deepseek-ai/dsh-storage": "^0.1.6-alpha.2",
58
- "@deepseek-ai/dsh-storage-domain": "^0.1.6-alpha.2",
59
- "@deepseek-ai/dsh-typert-protocol": "^0.1.6-alpha.2"
53
+ "@deepseek-ai/cordis": "^4.0.3",
54
+ "@deepseek-ai/dsh-session-persistence": "^0.1.7-alpha.1",
55
+ "@deepseek-ai/dsh-storage": "^0.1.7-alpha.1",
56
+ "@deepseek-ai/dsh-storage-domain": "^0.1.7-alpha.1",
57
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.7-alpha.1",
58
+ "@deepseek-ai/dsh-invariants": "^0.1.7-alpha.1",
59
+ "@deepseek-ai/dsh-session": "^0.1.7-alpha.1"
60
60
  }
61
61
  }