@namzu/sdk 13.0.0 → 14.0.0

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.
Files changed (119) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/dist/agents/runAgent.d.ts +9 -0
  3. package/dist/agents/runAgent.d.ts.map +1 -1
  4. package/dist/agents/runAgent.js.map +1 -1
  5. package/dist/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.d.ts +2 -0
  6. package/dist/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.d.ts.map +1 -0
  7. package/dist/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.js +101 -0
  8. package/dist/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.js.map +1 -0
  9. package/dist/connector/mcp/http-sse.d.ts +10 -0
  10. package/dist/connector/mcp/http-sse.d.ts.map +1 -1
  11. package/dist/connector/mcp/http-sse.js +16 -0
  12. package/dist/connector/mcp/http-sse.js.map +1 -1
  13. package/dist/connector/mcp/stdio.d.ts +12 -0
  14. package/dist/connector/mcp/stdio.d.ts.map +1 -1
  15. package/dist/connector/mcp/stdio.js +33 -1
  16. package/dist/connector/mcp/stdio.js.map +1 -1
  17. package/dist/connector/mcp/streamable-http.d.ts +2 -0
  18. package/dist/connector/mcp/streamable-http.d.ts.map +1 -1
  19. package/dist/connector/mcp/streamable-http.js +13 -1
  20. package/dist/connector/mcp/streamable-http.js.map +1 -1
  21. package/dist/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.d.ts +2 -0
  22. package/dist/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.d.ts.map +1 -0
  23. package/dist/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.js +73 -0
  24. package/dist/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.js.map +1 -0
  25. package/dist/contracts/schemas.d.ts +15 -0
  26. package/dist/contracts/schemas.d.ts.map +1 -1
  27. package/dist/contracts/schemas.js +18 -1
  28. package/dist/contracts/schemas.js.map +1 -1
  29. package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.d.ts +2 -0
  30. package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.d.ts.map +1 -0
  31. package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.js +159 -0
  32. package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.js.map +1 -0
  33. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  34. package/dist/manager/agent/lifecycle.js +39 -4
  35. package/dist/manager/agent/lifecycle.js.map +1 -1
  36. package/dist/manager/index.d.ts +2 -0
  37. package/dist/manager/index.d.ts.map +1 -1
  38. package/dist/manager/index.js +1 -0
  39. package/dist/manager/index.js.map +1 -1
  40. package/dist/manager/project/__tests__/a-closed-workspace-takes-no-work.test.d.ts +2 -0
  41. package/dist/manager/project/__tests__/a-closed-workspace-takes-no-work.test.d.ts.map +1 -0
  42. package/dist/manager/project/__tests__/a-closed-workspace-takes-no-work.test.js +208 -0
  43. package/dist/manager/project/__tests__/a-closed-workspace-takes-no-work.test.js.map +1 -0
  44. package/dist/manager/project/lifecycle.d.ts +66 -0
  45. package/dist/manager/project/lifecycle.d.ts.map +1 -0
  46. package/dist/manager/project/lifecycle.js +125 -0
  47. package/dist/manager/project/lifecycle.js.map +1 -0
  48. package/dist/public-runtime.d.ts +2 -2
  49. package/dist/public-runtime.d.ts.map +1 -1
  50. package/dist/public-runtime.js +7 -1
  51. package/dist/public-runtime.js.map +1 -1
  52. package/dist/session/errors.d.ts +71 -1
  53. package/dist/session/errors.d.ts.map +1 -1
  54. package/dist/session/errors.js +50 -0
  55. package/dist/session/errors.js.map +1 -1
  56. package/dist/session/handoff/broadcast.d.ts.map +1 -1
  57. package/dist/session/handoff/broadcast.js +4 -5
  58. package/dist/session/handoff/broadcast.js.map +1 -1
  59. package/dist/session/handoff/single.d.ts.map +1 -1
  60. package/dist/session/handoff/single.js +5 -4
  61. package/dist/session/handoff/single.js.map +1 -1
  62. package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.d.ts +2 -0
  63. package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.d.ts.map +1 -0
  64. package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.js +75 -0
  65. package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.js.map +1 -0
  66. package/dist/session/migration/filesystem.d.ts.map +1 -1
  67. package/dist/session/migration/filesystem.js +24 -0
  68. package/dist/session/migration/filesystem.js.map +1 -1
  69. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.d.ts +2 -0
  70. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.d.ts.map +1 -0
  71. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.js +149 -0
  72. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.js.map +1 -0
  73. package/dist/store/session/disk.d.ts +6 -2
  74. package/dist/store/session/disk.d.ts.map +1 -1
  75. package/dist/store/session/disk.js +120 -3
  76. package/dist/store/session/disk.js.map +1 -1
  77. package/dist/store/session/memory.d.ts +6 -2
  78. package/dist/store/session/memory.d.ts.map +1 -1
  79. package/dist/store/session/memory.js +78 -3
  80. package/dist/store/session/memory.js.map +1 -1
  81. package/dist/tools/coordinator/agent.d.ts.map +1 -1
  82. package/dist/tools/coordinator/agent.js +7 -0
  83. package/dist/tools/coordinator/agent.js.map +1 -1
  84. package/dist/tools/coordinator/index.d.ts.map +1 -1
  85. package/dist/tools/coordinator/index.js +6 -0
  86. package/dist/tools/coordinator/index.js.map +1 -1
  87. package/dist/types/agent/base.d.ts +22 -0
  88. package/dist/types/agent/base.d.ts.map +1 -1
  89. package/dist/types/project/entity.d.ts +17 -0
  90. package/dist/types/project/entity.d.ts.map +1 -1
  91. package/dist/types/session/store.d.ts +80 -1
  92. package/dist/types/session/store.d.ts.map +1 -1
  93. package/package.json +1 -1
  94. package/src/agents/runAgent.ts +9 -0
  95. package/src/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.ts +127 -0
  96. package/src/connector/mcp/http-sse.ts +17 -0
  97. package/src/connector/mcp/stdio.ts +34 -1
  98. package/src/connector/mcp/streamable-http.ts +15 -1
  99. package/src/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.ts +84 -0
  100. package/src/contracts/schemas.ts +18 -1
  101. package/src/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.ts +196 -0
  102. package/src/manager/agent/lifecycle.ts +42 -6
  103. package/src/manager/index.ts +3 -0
  104. package/src/manager/project/__tests__/a-closed-workspace-takes-no-work.test.ts +268 -0
  105. package/src/manager/project/lifecycle.ts +160 -0
  106. package/src/public-runtime.ts +9 -0
  107. package/src/session/errors.ts +85 -1
  108. package/src/session/handoff/broadcast.ts +4 -5
  109. package/src/session/handoff/single.ts +5 -4
  110. package/src/session/migration/__tests__/a-migrated-project-id-is-an-id.test.ts +92 -0
  111. package/src/session/migration/filesystem.ts +28 -0
  112. package/src/store/session/__tests__/a-workspace-can-be-configured.test.ts +210 -0
  113. package/src/store/session/disk.ts +142 -4
  114. package/src/store/session/memory.ts +95 -4
  115. package/src/tools/coordinator/agent.ts +7 -0
  116. package/src/tools/coordinator/index.ts +6 -0
  117. package/src/types/agent/base.ts +23 -0
  118. package/src/types/project/entity.ts +18 -0
  119. package/src/types/session/store.ts +95 -1
@@ -23,11 +23,11 @@
23
23
 
24
24
  import { appendFile, mkdir, readFile, readdir, rm } from 'node:fs/promises'
25
25
  import { join } from 'node:path'
26
- import { StaleSessionError, TenantIsolationError } from '../../session/errors.js'
26
+ import { StaleProjectError, StaleSessionError, TenantIsolationError } from '../../session/errors.js'
27
27
  import { SessionAlreadySummarizedError } from '../../session/summary/errors.js'
28
28
  import type { MessageId, SessionId, TenantId } from '../../types/ids/index.js'
29
29
  import type { Message } from '../../types/message/index.js'
30
- import type { Project } from '../../types/project/entity.js'
30
+ import type { Project, ProjectStatus } from '../../types/project/entity.js'
31
31
  import type { Session } from '../../types/session/entity.js'
32
32
  import type { ProjectId, SubSessionId, SummaryId, ThreadId } from '../../types/session/ids.js'
33
33
  import type { SessionMessage } from '../../types/session/messages.js'
@@ -35,6 +35,7 @@ import type {
35
35
  CreateProjectParams,
36
36
  CreateSessionParams,
37
37
  CreateSubSessionParams,
38
+ ProjectConfigInput,
38
39
  SessionStore,
39
40
  SessionView,
40
41
  } from '../../types/session/store.js'
@@ -79,6 +80,10 @@ interface PersistedProject {
79
80
  tenantId: TenantId
80
81
  name: string
81
82
  config: Project['config']
83
+ /** Absent in files written before the workspace gained a status. */
84
+ status?: ProjectStatus
85
+ /** Absent in files written before the workspace gained a CAS counter. */
86
+ ownerVersion?: number
82
87
  createdAt: string
83
88
  updatedAt: string
84
89
  }
@@ -193,10 +198,12 @@ export class DiskSessionStore implements SessionStore {
193
198
  tenantId,
194
199
  name: params.name,
195
200
  config: {
196
- maxDelegationDepth: 4,
197
- maxDelegationWidth: 8,
201
+ maxDelegationDepth: params.config?.maxDelegationDepth ?? 4,
202
+ maxDelegationWidth: params.config?.maxDelegationWidth ?? 8,
198
203
  maxInterventionDepth: 10,
199
204
  },
205
+ status: 'open',
206
+ ownerVersion: 0,
200
207
  createdAt: now,
201
208
  updatedAt: now,
202
209
  }
@@ -215,6 +222,95 @@ export class DiskSessionStore implements SessionStore {
215
222
  return deserializeProject(raw)
216
223
  }
217
224
 
225
+ async updateProject(
226
+ projectId: ProjectId,
227
+ config: ProjectConfigInput,
228
+ tenantId: TenantId,
229
+ ): Promise<Project | null> {
230
+ const existing = await this.getProject(projectId, tenantId)
231
+ if (!existing) return null
232
+ // Per field, like the in-memory store: an omitted limit is left alone
233
+ // rather than reset.
234
+ const project: Project = {
235
+ ...existing,
236
+ config: {
237
+ ...existing.config,
238
+ ...(config.maxDelegationDepth !== undefined
239
+ ? { maxDelegationDepth: config.maxDelegationDepth }
240
+ : {}),
241
+ ...(config.maxDelegationWidth !== undefined
242
+ ? { maxDelegationWidth: config.maxDelegationWidth }
243
+ : {}),
244
+ },
245
+ updatedAt: new Date(),
246
+ }
247
+ await atomicWriteJson(
248
+ join(this.projectDir(projectId), 'project.json'),
249
+ serializeProject(project),
250
+ )
251
+ return project
252
+ }
253
+
254
+ async setProjectStatus(
255
+ projectId: ProjectId,
256
+ status: ProjectStatus,
257
+ tenantId: TenantId,
258
+ expectedOwnerVersion: number,
259
+ ): Promise<Project | null> {
260
+ const existing = await this.getProject(projectId, tenantId)
261
+ if (!existing) return null
262
+ // Against the version on disk, not the caller's copy of it.
263
+ if (existing.ownerVersion !== expectedOwnerVersion) {
264
+ throw new StaleProjectError({
265
+ projectId,
266
+ expectedOwnerVersion,
267
+ actualOwnerVersion: existing.ownerVersion,
268
+ })
269
+ }
270
+ const project: Project = {
271
+ ...existing,
272
+ status,
273
+ ownerVersion: existing.ownerVersion + 1,
274
+ updatedAt: new Date(),
275
+ }
276
+ await atomicWriteJson(
277
+ join(this.projectDir(projectId), 'project.json'),
278
+ serializeProject(project),
279
+ )
280
+ return project
281
+ }
282
+
283
+ async listProjects(tenantId: TenantId): Promise<readonly Project[]> {
284
+ // Read from the directory rather than the lazily-built index: the index
285
+ // only knows about projects this instance has already touched, so a
286
+ // listing built from it would omit everything written by a previous
287
+ // process — which for a store whose whole point is durability is the
288
+ // wrong answer.
289
+ const projectsRoot = join(this.rootDir, 'projects')
290
+ let entries: string[]
291
+ try {
292
+ entries = await readdir(projectsRoot)
293
+ } catch {
294
+ return []
295
+ }
296
+
297
+ const found: Project[] = []
298
+ for (const entry of entries) {
299
+ const raw = await readJson<PersistedProject>(join(projectsRoot, entry, 'project.json'))
300
+ if (!raw) continue
301
+ // Another tenant's project is absent, not an error — a listing is a
302
+ // question about what you own, and refusing would leak that
303
+ // somebody else's project is there.
304
+ if (raw.tenantId !== tenantId) continue
305
+ found.push(deserializeProject(raw))
306
+ }
307
+ // Tie-broken by id — see the in-memory store. On a fast filesystem two
308
+ // projects share a millisecond routinely, and without this the order
309
+ // came from readdir.
310
+ found.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id))
311
+ return found
312
+ }
313
+
218
314
  // Session CRUD ------------------------------------------------------------
219
315
 
220
316
  async createSession(params: CreateSessionParams, tenantId: TenantId): Promise<Session> {
@@ -308,6 +404,40 @@ export class DiskSessionStore implements SessionStore {
308
404
  return results
309
405
  }
310
406
 
407
+ async listSessionsByProject(
408
+ projectId: ProjectId,
409
+ tenantId: TenantId,
410
+ ): Promise<readonly Session[]> {
411
+ // One directory, not the whole root: sessions live under their project,
412
+ // so this is the cheap direction. `listSessions` has to scan every
413
+ // project precisely because `threadId` is denormalised onto the record
414
+ // rather than expressed in the layout.
415
+ const sessionsRoot = join(this.projectDir(projectId), 'sessions')
416
+ let sessionDirs: string[]
417
+ try {
418
+ sessionDirs = await readdir(sessionsRoot)
419
+ } catch (err) {
420
+ const code = (err as NodeJS.ErrnoException).code
421
+ if (code === 'ENOENT') return []
422
+ throw err
423
+ }
424
+
425
+ const results: Session[] = []
426
+ for (const rawSessionId of sessionDirs) {
427
+ if (!rawSessionId.startsWith('ses_')) continue
428
+ const path = join(sessionsRoot, rawSessionId)
429
+ const raw = await readJson<PersistedSession>(join(path, 'session.json'))
430
+ if (!raw) continue
431
+ if (raw.tenantId !== tenantId) continue
432
+ results.push(deserializeSession(raw))
433
+ this.sessionIndex.set(raw.id, { sessionId: raw.id, projectId, path })
434
+ }
435
+ results.sort(
436
+ (a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id),
437
+ )
438
+ return results
439
+ }
440
+
311
441
  async updateSession(
312
442
  session: Session,
313
443
  tenantId: TenantId,
@@ -849,6 +979,8 @@ function serializeProject(p: Project): PersistedProject {
849
979
  tenantId: p.tenantId,
850
980
  name: p.name,
851
981
  config: p.config,
982
+ status: p.status,
983
+ ownerVersion: p.ownerVersion,
852
984
  createdAt: p.createdAt.toISOString(),
853
985
  updatedAt: p.updatedAt.toISOString(),
854
986
  }
@@ -860,6 +992,12 @@ function deserializeProject(p: PersistedProject): Project {
860
992
  tenantId: p.tenantId,
861
993
  name: p.name,
862
994
  config: p.config,
995
+ // A project.json written before these fields existed reads as an open
996
+ // workspace at version 0, which is what it was. Leaving `ownerVersion`
997
+ // undefined would be worse than a wrong default: every compare-and-set
998
+ // against it would fail, so an existing store could never be closed.
999
+ status: p.status ?? 'open',
1000
+ ownerVersion: p.ownerVersion ?? 0,
863
1001
  createdAt: new Date(p.createdAt),
864
1002
  updatedAt: new Date(p.updatedAt),
865
1003
  }
@@ -8,11 +8,11 @@
8
8
  * (Convention #5 deny-by-default, session-hierarchy.md §12.2).
9
9
  */
10
10
 
11
- import { StaleSessionError, TenantIsolationError } from '../../session/errors.js'
11
+ import { StaleProjectError, StaleSessionError, TenantIsolationError } from '../../session/errors.js'
12
12
  import { SessionAlreadySummarizedError } from '../../session/summary/errors.js'
13
13
  import type { MessageId, SessionId, TenantId } from '../../types/ids/index.js'
14
14
  import type { Message } from '../../types/message/index.js'
15
- import type { Project } from '../../types/project/entity.js'
15
+ import type { Project, ProjectStatus } from '../../types/project/entity.js'
16
16
  import type { Session } from '../../types/session/entity.js'
17
17
  import type { ProjectId, SubSessionId, ThreadId } from '../../types/session/ids.js'
18
18
  import type { SessionMessage } from '../../types/session/messages.js'
@@ -20,6 +20,7 @@ import type {
20
20
  CreateProjectParams,
21
21
  CreateSessionParams,
22
22
  CreateSubSessionParams,
23
+ ProjectConfigInput,
23
24
  SessionStore,
24
25
  SessionView,
25
26
  } from '../../types/session/store.js'
@@ -88,10 +89,12 @@ export class InMemorySessionStore implements SessionStore {
88
89
  tenantId,
89
90
  name: params.name,
90
91
  config: {
91
- maxDelegationDepth: 4,
92
- maxDelegationWidth: 8,
92
+ maxDelegationDepth: params.config?.maxDelegationDepth ?? 4,
93
+ maxDelegationWidth: params.config?.maxDelegationWidth ?? 8,
93
94
  maxInterventionDepth: 10,
94
95
  },
96
+ status: 'open',
97
+ ownerVersion: 0,
95
98
  createdAt: now,
96
99
  updatedAt: now,
97
100
  }
@@ -106,6 +109,78 @@ export class InMemorySessionStore implements SessionStore {
106
109
  return record.project
107
110
  }
108
111
 
112
+ async updateProject(
113
+ projectId: ProjectId,
114
+ config: ProjectConfigInput,
115
+ tenantId: TenantId,
116
+ ): Promise<Project | null> {
117
+ const record = this.projects.get(projectId)
118
+ if (!record) return null
119
+ this.assertTenant(record.tenantId, tenantId, `project(${projectId})`)
120
+ // Per field: an omitted limit is left alone rather than reset, because a
121
+ // caller raising the width is saying nothing about the depth.
122
+ const project: Project = {
123
+ ...record.project,
124
+ config: {
125
+ ...record.project.config,
126
+ ...(config.maxDelegationDepth !== undefined
127
+ ? { maxDelegationDepth: config.maxDelegationDepth }
128
+ : {}),
129
+ ...(config.maxDelegationWidth !== undefined
130
+ ? { maxDelegationWidth: config.maxDelegationWidth }
131
+ : {}),
132
+ },
133
+ updatedAt: new Date(),
134
+ }
135
+ this.projects.set(projectId, { tenantId, project })
136
+ return project
137
+ }
138
+
139
+ async setProjectStatus(
140
+ projectId: ProjectId,
141
+ status: ProjectStatus,
142
+ tenantId: TenantId,
143
+ expectedOwnerVersion: number,
144
+ ): Promise<Project | null> {
145
+ const record = this.projects.get(projectId)
146
+ if (!record) return null
147
+ this.assertTenant(record.tenantId, tenantId, `project(${projectId})`)
148
+ // Against the STORED version, not the caller's copy of it — comparing a
149
+ // value against itself is the shape the session CAS was written wrong in
150
+ // the first time.
151
+ if (record.project.ownerVersion !== expectedOwnerVersion) {
152
+ throw new StaleProjectError({
153
+ projectId,
154
+ expectedOwnerVersion,
155
+ actualOwnerVersion: record.project.ownerVersion,
156
+ })
157
+ }
158
+ const project: Project = {
159
+ ...record.project,
160
+ status,
161
+ ownerVersion: record.project.ownerVersion + 1,
162
+ updatedAt: new Date(),
163
+ }
164
+ this.projects.set(projectId, { tenantId, project })
165
+ return project
166
+ }
167
+
168
+ async listProjects(tenantId: TenantId): Promise<readonly Project[]> {
169
+ const matches: Project[] = []
170
+ for (const record of this.projects.values()) {
171
+ if (record.tenantId !== tenantId) continue
172
+ matches.push(record.project)
173
+ }
174
+ // Tie-broken by id, because two projects created in the same
175
+ // millisecond otherwise fall back to insertion or directory order and
176
+ // "oldest first" stops being a total order. A caller paginating a
177
+ // listing that reorders under it sees items move between pages.
178
+ matches.sort(
179
+ (a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id),
180
+ )
181
+ return matches
182
+ }
183
+
109
184
  // Session CRUD ------------------------------------------------------------
110
185
 
111
186
  async createSession(params: CreateSessionParams, tenantId: TenantId): Promise<Session> {
@@ -151,6 +226,22 @@ export class InMemorySessionStore implements SessionStore {
151
226
  return matches
152
227
  }
153
228
 
229
+ async listSessionsByProject(
230
+ projectId: ProjectId,
231
+ tenantId: TenantId,
232
+ ): Promise<readonly Session[]> {
233
+ const matches: Session[] = []
234
+ for (const record of this.sessions.values()) {
235
+ if (record.tenantId !== tenantId) continue
236
+ if (record.session.projectId !== projectId) continue
237
+ matches.push(record.session)
238
+ }
239
+ matches.sort(
240
+ (a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id),
241
+ )
242
+ return matches
243
+ }
244
+
154
245
  async updateSession(
155
246
  session: Session,
156
247
  tenantId: TenantId,
@@ -146,6 +146,13 @@ export function buildAgentTool(opts: AgentToolOptions): ToolDefinition {
146
146
  // surface, and the one it exports as the canonical shape —
147
147
  // did not.
148
148
  ...(context.parentSpan ? { parentSpan: context.parentSpan } : {}),
149
+ // The parent's environment, which is the whole point of setting
150
+ // one: a delegate that cannot see it runs against different
151
+ // services than the run that launched it, silently.
152
+ // `ToolContext.env` is the parent's own resolved map, per run.
153
+ ...(Object.keys(context.env ?? {}).length > 0
154
+ ? { configOverrides: { env: context.env } }
155
+ : {}),
149
156
  })
150
157
 
151
158
  onTaskLaunched?.(handle.taskId, {
@@ -484,6 +484,12 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
484
484
  // Hang the child run off THIS tool's span, so the delegation
485
485
  // shows up inside the turn that asked for it.
486
486
  ...(_context.parentSpan ? { parentSpan: _context.parentSpan } : {}),
487
+ // Same as the `Agent` tool: a delegate inherits the environment
488
+ // its parent was given, or it runs against different services
489
+ // than the run that asked for the work.
490
+ ...(Object.keys(_context.env ?? {}).length > 0
491
+ ? { configOverrides: { env: _context.env } }
492
+ : {}),
487
493
  })
488
494
 
489
495
  // Whose task this is. The inbox ignores completions for anything it
@@ -22,6 +22,29 @@ export interface BaseAgentConfig {
22
22
  maxResponseTokens?: number
23
23
  costLimitUsd?: number
24
24
  permissionMode?: PermissionMode
25
+
26
+ /**
27
+ * Extra environment variables for this agent's tools and sandboxed
28
+ * commands, merged over whatever ambient environment the execution path
29
+ * supplies. Inherited by every delegated descendant.
30
+ *
31
+ * **Configuration, not credentials** — and that is a property of the
32
+ * CHANNEL rather than a judgement about any particular value. This map is
33
+ * copied into every child, is readable by any tool that can run a command,
34
+ * and enters a model's context and the run transcript the moment something
35
+ * echoes it. Nothing here is scoped, redacted, or revocable.
36
+ *
37
+ * A value that authenticates to a host belongs on the brokered credential
38
+ * path instead, where the process holds a placeholder and the real value is
39
+ * attached per-host on egress — so it is never in the environment, never in
40
+ * a transcript, and never inherited by a child that had no business with it.
41
+ *
42
+ * Inheritance was broken until it was not: a child built through a
43
+ * `configBuilder` never received this at all, because the builder is
44
+ * written by whoever registered the agent and cannot forward a field it was
45
+ * never told about. It is stamped after the builder returns now, for the
46
+ * same reason `parentSpan` and `resumeHandler` are.
47
+ */
25
48
  env?: Record<string, string>
26
49
 
27
50
  /**
@@ -27,15 +27,33 @@ export interface ProjectConfig {
27
27
  retentionPolicy?: RetentionPolicy
28
28
  }
29
29
 
30
+ /**
31
+ * Whether the workspace accepts new work.
32
+ *
33
+ * Owner-managed, like the status it replaces: a project does not derive
34
+ * `archived` from having no live sessions, because "empty right now" and
35
+ * "closed" are different facts and only one of them is a decision.
36
+ */
37
+ export type ProjectStatus = 'open' | 'archived'
38
+
30
39
  /**
31
40
  * Long-lived goal scope that owns shared memory, vaults, knowledge bases,
32
41
  * and deliverables across sessions. See session-hierarchy.md §4.2.
42
+ *
43
+ * `status` is the gate the ingress paths read: an archived project accepts no
44
+ * new session and no handoff. It lives here rather than on Thread because the
45
+ * project is the thing a tenant actually closes — a workspace with its own
46
+ * limits, its own environment, and its own memory — and closing it has to mean
47
+ * something to the code, not only to a listing.
33
48
  */
34
49
  export interface Project {
35
50
  id: ProjectId
36
51
  tenantId: TenantId
37
52
  name: string
38
53
  config: ProjectConfig
54
+ status: ProjectStatus
55
+ /** CAS counter for status transitions. Mirrors `Session.ownerVersion`. */
56
+ ownerVersion: number
39
57
  createdAt: Date
40
58
  updatedAt: Date
41
59
  }
@@ -6,7 +6,7 @@
6
6
  * API — extensions land alongside their consumers.
7
7
  */
8
8
 
9
- import type { Project } from '../../types/project/entity.js'
9
+ import type { Project, ProjectStatus } from '../../types/project/entity.js'
10
10
  import type { ActorRef } from '../../types/session/actor.js'
11
11
  import type { Session } from '../../types/session/entity.js'
12
12
  import type { SessionMessage } from '../../types/session/messages.js'
@@ -65,9 +65,42 @@ export interface CreateSubSessionParams {
65
65
  * is out of scope for this phase (session-hierarchy.md §11 defers the project
66
66
  * store to a later phase).
67
67
  */
68
+ /**
69
+ * The part of a Project's configuration a caller may actually set.
70
+ *
71
+ * **Exactly the fields something reads.** `ProjectConfig` declares eight; five
72
+ * enforcement sites read two of them, and the other six have zero readers in
73
+ * production — `maxInterventionDepth` included, whose three apparent hits are
74
+ * all comments claiming a wiring that does not exist. Exposing those here
75
+ * would make a dead field *easier to set*, which is worse than leaving it
76
+ * unreachable: a host would configure a retention policy, get no error, and
77
+ * believe retention was on.
78
+ *
79
+ * The rule is the repo's own: name the code that reads a declaration before
80
+ * shipping it. When a field gains a reader it gains a line here in the same
81
+ * change, and not before.
82
+ */
83
+ export interface ProjectConfigInput {
84
+ /** Read by the spawn path and both handoff paths. Default 4. */
85
+ maxDelegationDepth?: number
86
+ /** Read by the spawn path and broadcast handoff. Default 8. */
87
+ maxDelegationWidth?: number
88
+ }
89
+
68
90
  export interface CreateProjectParams {
69
91
  tenantId: TenantId
70
92
  name: string
93
+
94
+ /**
95
+ * Per-workspace limits. Omitted fields keep the defaults.
96
+ *
97
+ * Until this existed every project in existence ran at depth 4 / width 8,
98
+ * because the config was hardcoded identically in both stores and there was
99
+ * no way to write one afterwards. A tenant with several workspaces could
100
+ * not give them different limits, which is most of what having several
101
+ * workspaces is for.
102
+ */
103
+ config?: ProjectConfigInput
71
104
  }
72
105
 
73
106
  /**
@@ -99,6 +132,56 @@ export interface SessionStore {
99
132
 
100
133
  getProject(projectId: ProjectId, tenantId: TenantId): Promise<Project | null>
101
134
 
135
+ /**
136
+ * Change a Project's limits after it exists. OPTIONAL.
137
+ *
138
+ * Optional because widening a store interface is invisible to callers and
139
+ * fatal to implementors: a host with its own `SessionStore` should not stop
140
+ * compiling because the SDK grew a method. Callers check for it; the two
141
+ * stores here implement it.
142
+ *
143
+ * Only the fields in {@link ProjectConfigInput} can move, and an omitted
144
+ * field is left alone rather than reset — a caller raising the width is not
145
+ * saying anything about the depth. Returns the updated Project, or `null`
146
+ * if it does not exist.
147
+ */
148
+ updateProject?(
149
+ projectId: ProjectId,
150
+ config: ProjectConfigInput,
151
+ tenantId: TenantId,
152
+ ): Promise<Project | null>
153
+
154
+ /**
155
+ * Every Project this tenant owns, oldest first. OPTIONAL, same reasoning.
156
+ *
157
+ * The tenant is the isolation boundary, so this is scoped to it and to
158
+ * nothing else — there is no level above Project to filter by.
159
+ */
160
+ listProjects?(tenantId: TenantId): Promise<readonly Project[]>
161
+
162
+ /**
163
+ * Open or close a workspace. OPTIONAL, same reasoning as the two above.
164
+ *
165
+ * Compare-and-set on {@link Project.ownerVersion}: pass the version you
166
+ * read, and a concurrent writer makes this throw `StaleProjectError`
167
+ * instead of silently winning. On success the stored version is bumped.
168
+ *
169
+ * Both directions, because a workspace is long-lived and closing one by
170
+ * mistake should not be permanent — unlike the Thread status this replaces,
171
+ * which only ever went one way. Returns `null` if the project does not
172
+ * exist; writing to another tenant's project throws.
173
+ *
174
+ * This is the store-level write. The precondition that no live session is
175
+ * attached belongs to {@link import('../../manager/project/lifecycle.js').ProjectManager},
176
+ * because the store deliberately holds no view of what is running.
177
+ */
178
+ setProjectStatus?(
179
+ projectId: ProjectId,
180
+ status: ProjectStatus,
181
+ tenantId: TenantId,
182
+ expectedOwnerVersion: number,
183
+ ): Promise<Project | null>
184
+
102
185
  // Session CRUD ------------------------------------------------------------
103
186
 
104
187
  createSession(params: CreateSessionParams, tenantId: TenantId): Promise<Session>
@@ -153,6 +236,17 @@ export interface SessionStore {
153
236
  */
154
237
  listSessions(threadId: ThreadId, tenantId: TenantId): Promise<readonly Session[]>
155
238
 
239
+ /**
240
+ * Every Session attached to a workspace, oldest first. OPTIONAL.
241
+ *
242
+ * The project-scoped sibling of {@link SessionStore.listSessions}, and the
243
+ * one the archive precondition reads: closing a workspace has to know what
244
+ * is still running in it, and "what is running in this thread" was never
245
+ * the question — a project can hold sessions across many threads, and after
246
+ * the Thread level is removed it is the only grouping left.
247
+ */
248
+ listSessionsByProject?(projectId: ProjectId, tenantId: TenantId): Promise<readonly Session[]>
249
+
156
250
  /**
157
251
  * Hard-delete a session. Idempotent — absent sessions succeed as a no-op.
158
252
  * Rejects with `TenantIsolationError` on cross-tenant access.