@agentpulselabs/cli-plugin-pulse 0.1.0 → 1.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 (68) hide show
  1. package/README.md +69 -32
  2. package/package.json +10 -10
  3. package/src/commands/pulse/doctor.js +14 -0
  4. package/src/commands/pulse/hub-write.js +26 -0
  5. package/src/commands/pulse/install.js +14 -0
  6. package/src/commands/pulse/login.js +20 -0
  7. package/src/commands/pulse/mirror.js +27 -0
  8. package/src/commands/pulse/pair.js +38 -0
  9. package/src/commands/pulse/restart.js +14 -0
  10. package/src/commands/pulse/start.js +8 -18
  11. package/src/commands/pulse/status.js +8 -7
  12. package/src/commands/pulse/stop.js +24 -0
  13. package/src/daemon/main.js +36 -0
  14. package/src/lib/BaseCommand.js +37 -0
  15. package/src/lib/agentSessions.js +279 -0
  16. package/src/lib/application.js +656 -0
  17. package/src/lib/backoff.js +8 -0
  18. package/src/lib/budgets.js +85 -0
  19. package/src/lib/cliDefaults.js +20 -0
  20. package/src/lib/cloudAuth.js +189 -0
  21. package/src/lib/contract.sha256 +2 -0
  22. package/src/lib/daemonComposition.js +338 -0
  23. package/src/lib/daemonLock.js +55 -0
  24. package/src/lib/daemonLoop.js +69 -0
  25. package/src/lib/daemonProcess.js +366 -0
  26. package/src/lib/daemonRuntime.js +820 -0
  27. package/src/lib/drivers.js +280 -0
  28. package/src/lib/errors.js +19 -0
  29. package/src/lib/graphWindows.js +149 -0
  30. package/src/lib/http.js +84 -0
  31. package/src/lib/hubActions.js +74 -0
  32. package/src/lib/identity.js +102 -0
  33. package/src/lib/intents.js +590 -0
  34. package/src/lib/ledger.js +535 -0
  35. package/src/lib/lifecycle.js +123 -0
  36. package/src/lib/localHub.js +217 -0
  37. package/src/lib/localHubWriter.js +69 -0
  38. package/src/lib/mirror-contract.json +4299 -0
  39. package/src/lib/mirrorContract.js +513 -0
  40. package/src/lib/mirrorDaemon.js +127 -0
  41. package/src/lib/mirrorService.js +358 -0
  42. package/src/lib/mirrorSource.js +455 -0
  43. package/src/lib/mirrorSync.js +353 -0
  44. package/src/lib/oauthLogin.js +132 -0
  45. package/src/lib/ownerLock.js +99 -0
  46. package/src/lib/pairedConfig.js +40 -0
  47. package/src/lib/pairingLifecycle.js +179 -0
  48. package/src/lib/pathRedaction.js +89 -0
  49. package/src/lib/planContract.js +141 -0
  50. package/src/lib/priorityLane.js +80 -0
  51. package/src/lib/processOwnership.js +252 -0
  52. package/src/lib/processSupervisor.js +376 -0
  53. package/src/lib/redaction.js +128 -0
  54. package/src/lib/rootPolicy.js +372 -0
  55. package/src/lib/rulePacks.js +162 -0
  56. package/src/lib/secretStore.js +169 -0
  57. package/src/lib/selfRestart.js +261 -0
  58. package/src/lib/serviceManager.js +358 -0
  59. package/src/lib/storage.js +131 -0
  60. package/src/lib/subprocess.js +45 -0
  61. package/src/lib/tokenStore.js +97 -0
  62. package/src/lib/transport.js +286 -0
  63. package/src/lib/transportContract.js +37 -0
  64. package/src/commands/pulse/gate.js +0 -23
  65. package/src/commands/pulse/next.js +0 -13
  66. package/src/commands/pulse/submit.js +0 -19
  67. package/src/commands/pulse/verify.js +0 -18
  68. package/src/runPulse.js +0 -58
@@ -0,0 +1,590 @@
1
+ import { assertNever, HubError } from './errors.js';
2
+
3
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
4
+ const OPAQUE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
5
+ const SHA256 = /^[0-9a-f]{64}$/;
6
+ const FORBIDDEN_FIELDS = new Set([
7
+ 'command', 'content', 'credential', 'cwd', 'env', 'exec', 'executable',
8
+ 'privatekey', 'prompt', 'script', 'source', 'shell', 'socket', 'stderr',
9
+ 'stdout', 'token', 'transcript',
10
+ ]);
11
+ const SECRET_VALUE = /(?:-----BEGIN [A-Z ]*PRIVATE KEY-----|(?:api[_-]?key|access[_-]?token|client[_-]?secret|password)\s*[:=]\s*\S+)/i;
12
+ const RESULT_FIELDS = Object.freeze({
13
+ 'delivery.plan': ['plan', 'planDigest'],
14
+ 'workspace.inspect': ['summary', 'repo', 'fileManifestHash'],
15
+ 'worktree.create': ['summary', 'branch', 'worktreeRef', 'repoHash'],
16
+ 'git.inspect': ['summary', 'status', 'diffSummaryHash'],
17
+ 'agent.start': ['summary', 'agentSessionRefHash'],
18
+ 'agent.turn': ['summary', 'usage', 'evidenceIds', 'needsUser', 'decision', 'decisionDigest'],
19
+ 'agent.stop': ['summary', 'stopped'],
20
+ 'evidence.collect': ['summary', 'artifacts'],
21
+ 'hub.chat.post': ['outcome', 'summary'],
22
+ 'hub.nudge': ['outcome', 'summary'],
23
+ 'hub.decision': ['outcome', 'summary'],
24
+ 'hub.accept': ['outcome', 'summary'],
25
+ 'hub.bus.reply': ['outcome', 'summary'],
26
+ 'hub.status.set': ['outcome', 'summary'],
27
+ });
28
+ /** What a hub action did, as the cloud stores it; the same set the cloud's jobSchemas accepts. */
29
+ export const HUB_OUTCOMES = Object.freeze(['posted', 'sent', 'queued', 'recorded', 'accepted', 'replied']);
30
+ /** The local hub's own write limits (agents-hub/server/http.mjs), so a job never carries text the hub would cut. */
31
+ export const HUB_TEXT_LIMITS = Object.freeze({ chat: 4000, nudge: 500, decision: 4000, acceptNote: 500, busReply: 4000, statusReason: 500, statusTo: 120 });
32
+ /** The statuses a person sets by hand on a delivery or an agent (agents-hub lib/status.mjs). */
33
+ export const HUB_STATUSES = Object.freeze(['done', 'blocked', 'stopped', 'handed-over', 'working']);
34
+ /** The Board's step columns, as a card moved to one names it (agents-hub lib/status.mjs STEP_IDS). */
35
+ export const HUB_STEPS = Object.freeze(['research', 'design', 'mockup', 'build', 'review', 'checks', 'proof']);
36
+ const HUB_CHANNEL = /^(?:task:[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+|project:[^\0\n]{1,200}|dm:[A-Za-z0-9][A-Za-z0-9._:@-]{0,199})$/;
37
+ const HUB_CHANNEL_MAX = 209;
38
+ const HUB_STREAM_KEY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
39
+ const HUB_LOCAL_ID = /^[^\0\n]{1,200}$/;
40
+ const HUB_TARGET_ID = /^[^\0-\x1f\x7f]{1,100}$/;
41
+ const ARTIFACT_KINDS = new Set([
42
+ 'plan', 'summary', 'check_result', 'screenshot', 'diff_summary', 'file_manifest', 'review',
43
+ ]);
44
+ const ARTIFACT_STORAGE = new Set(['inline_redacted', 'signed_reference', 'local_only']);
45
+
46
+ export const INTENT_KINDS = Object.freeze([
47
+ 'workspace.read',
48
+ 'workspace.write',
49
+ 'git.read',
50
+ 'git.write',
51
+ 'worktree.create',
52
+ 'agent.cursor.invoke',
53
+ 'agent.claude.invoke',
54
+ 'hub.write',
55
+ ]);
56
+
57
+ export const HUB_JOB_TYPES = Object.freeze(['hub.chat.post', 'hub.nudge', 'hub.decision', 'hub.accept', 'hub.bus.reply', 'hub.status.set']);
58
+
59
+ export const JOB_TYPES = Object.freeze([
60
+ 'delivery.plan',
61
+ 'workspace.inspect',
62
+ 'worktree.create',
63
+ 'git.inspect',
64
+ 'agent.start',
65
+ 'agent.turn',
66
+ 'agent.stop',
67
+ 'evidence.collect',
68
+ ...HUB_JOB_TYPES,
69
+ ]);
70
+
71
+ const JOB_SCHEMAS = Object.freeze({
72
+ 'delivery.plan': {
73
+ required: ['description', 'rootIds', 'budget'],
74
+ allowed: ['description', 'rootIds', 'budget'],
75
+ },
76
+ 'workspace.inspect': {
77
+ required: ['rootId'],
78
+ allowed: ['rootId', 'includeGit'],
79
+ },
80
+ 'worktree.create': {
81
+ required: ['rootId', 'branch', 'worktreeRef'],
82
+ allowed: ['rootId', 'branch', 'worktreeRef'],
83
+ },
84
+ 'git.inspect': {
85
+ required: ['rootId'],
86
+ allowed: ['rootId', 'ref', 'includeDiffSummary'],
87
+ },
88
+ 'agent.start': {
89
+ required: ['agentId', 'rootId', 'provider', 'model', 'role', 'intent'],
90
+ allowed: ['agentId', 'rootId', 'provider', 'model', 'role', 'intent', 'budget'],
91
+ },
92
+ 'agent.turn': {
93
+ required: ['agentSessionId', 'rootId', 'intent'],
94
+ allowed: ['agentSessionId', 'rootId', 'intent', 'budget'],
95
+ },
96
+ 'agent.stop': {
97
+ required: ['agentSessionId', 'rootId', 'reason'],
98
+ allowed: ['agentSessionId', 'rootId', 'reason'],
99
+ },
100
+ 'evidence.collect': {
101
+ required: ['rootId', 'kinds'],
102
+ allowed: ['rootId', 'kinds', 'maxBytes'],
103
+ },
104
+ 'hub.chat.post': { required: ['ch', 'text'], allowed: ['ch', 'text'] },
105
+ 'hub.nudge': { required: ['to', 'type', 'text'], allowed: ['to', 'type', 'text'] },
106
+ 'hub.decision': { required: ['key', 'kind', 'text'], allowed: ['key', 'kind', 'text', 'ref'] },
107
+ 'hub.accept': { required: ['delivery'], allowed: ['delivery', 'note'] },
108
+ 'hub.bus.reply': { required: ['id', 'text'], allowed: ['id', 'text'] },
109
+ 'hub.status.set': { required: ['target'], allowed: ['target', 'status', 'reason', 'to', 'step'] },
110
+ });
111
+
112
+ function inputError(message) {
113
+ return new HubError('invalid_job_arguments', message);
114
+ }
115
+
116
+ function assertNoAuthorityExpansion(value) {
117
+ if (!value || typeof value !== 'object') return;
118
+ if (Array.isArray(value)) {
119
+ value.forEach(assertNoAuthorityExpansion);
120
+ return;
121
+ }
122
+ for (const [key, entry] of Object.entries(value)) {
123
+ if (FORBIDDEN_FIELDS.has(key.toLowerCase())) {
124
+ throw inputError(`Field "${key}" is not permitted`);
125
+ }
126
+ assertNoAuthorityExpansion(entry);
127
+ }
128
+ }
129
+
130
+ function assertSafeCloudValue(value) {
131
+ if (typeof value === 'string') {
132
+ if (SECRET_VALUE.test(value)) throw inputError('Cloud value appears to contain secret material');
133
+ return;
134
+ }
135
+ if (!value || typeof value !== 'object') return;
136
+ if (Array.isArray(value)) {
137
+ value.forEach(assertSafeCloudValue);
138
+ return;
139
+ }
140
+ for (const [key, entry] of Object.entries(value)) {
141
+ if (FORBIDDEN_FIELDS.has(key.toLowerCase())) {
142
+ throw inputError(`Field "${key}" is not permitted in cloud storage`);
143
+ }
144
+ assertSafeCloudValue(entry);
145
+ }
146
+ }
147
+
148
+ function assertString(value, name, maximum, { optional = false } = {}) {
149
+ if (optional && value === undefined) return;
150
+ if (typeof value !== 'string' || !value.trim() || value.length > maximum || value.includes('\0')) {
151
+ throw inputError(`${name} must be a bounded non-empty string`);
152
+ }
153
+ }
154
+
155
+ function assertRootId(value) {
156
+ assertString(value, 'rootId', 80);
157
+ if (
158
+ !OPAQUE_ID.test(value) ||
159
+ value === '.' ||
160
+ value === '..' ||
161
+ value.includes('..') ||
162
+ value.startsWith('/')
163
+ ) {
164
+ throw inputError('rootId must be an opaque identifier, not a path');
165
+ }
166
+ }
167
+
168
+ function assertLocalId(value, name, maximum = 200) {
169
+ if (typeof value !== 'string' || !HUB_LOCAL_ID.test(value) || value.trim() !== value || value.length > maximum) {
170
+ throw inputError(`${name} must be a local hub id`);
171
+ }
172
+ }
173
+
174
+ function assertUuid(value, name) {
175
+ if (!UUID.test(String(value || ''))) throw inputError(`${name} must be a UUID`);
176
+ }
177
+
178
+ function assertBudget(value) {
179
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
180
+ throw inputError('budget is required');
181
+ }
182
+ const allowed = new Set(['maxTokens', 'maxCostUsd', 'maxWallMs', 'maxOutputBytes']);
183
+ for (const [key, amount] of Object.entries(value)) {
184
+ if (!allowed.has(key) || !Number.isFinite(amount) || amount <= 0) {
185
+ throw inputError(`budget.${key} is invalid`);
186
+ }
187
+ }
188
+ if (Object.keys(value).length === 0) throw inputError('budget requires explicit limits');
189
+ }
190
+
191
+ function assertStructuredIntent(intent) {
192
+ if (!intent || typeof intent !== 'object' || Array.isArray(intent)) {
193
+ throw inputError('intent must be a structured object');
194
+ }
195
+ const allowed = new Set(['kind', 'subjectId', 'subjectVersion', 'subjectDigest', 'outcome']);
196
+ if (Object.keys(intent).some((field) => !allowed.has(field))) {
197
+ throw inputError('intent contains an unsupported field');
198
+ }
199
+ if (!['delivery_task', 'decision_response', 'status_request', 'resume'].includes(intent.kind)) {
200
+ throw inputError('intent kind is invalid');
201
+ }
202
+ assertUuid(intent.subjectId, 'intent.subjectId');
203
+ if (!Number.isInteger(intent.subjectVersion) || intent.subjectVersion < 1) {
204
+ throw inputError('intent.subjectVersion must be positive');
205
+ }
206
+ if (!SHA256.test(String(intent.subjectDigest || ''))) {
207
+ throw inputError('intent.subjectDigest must be SHA-256 hex');
208
+ }
209
+ if (intent.kind === 'decision_response') {
210
+ if (
211
+ !intent.outcome ||
212
+ typeof intent.outcome !== 'object' ||
213
+ Array.isArray(intent.outcome) ||
214
+ Object.keys(intent.outcome).some(
215
+ (key) => !['optionId', 'label', 'impact', 'decision'].includes(key)
216
+ )
217
+ ) {
218
+ throw inputError('decision response requires a bounded outcome');
219
+ }
220
+ assertString(intent.outcome.optionId, 'intent.outcome.optionId', 80);
221
+ assertString(intent.outcome.label, 'intent.outcome.label', 240);
222
+ assertString(intent.outcome.impact, 'intent.outcome.impact', 1000, { optional: true });
223
+ if (!['approved', 'rejected'].includes(intent.outcome.decision)) {
224
+ throw inputError('intent.outcome.decision is invalid');
225
+ }
226
+ } else if (intent.outcome !== undefined) {
227
+ throw inputError('intent outcome is only allowed for decision responses');
228
+ }
229
+ }
230
+
231
+ function validateArguments(type, args) {
232
+ const schema = JOB_SCHEMAS[type];
233
+ if (!schema) throw inputError(`Unsupported job type "${type}"`);
234
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
235
+ throw inputError('arguments must be an object');
236
+ }
237
+ if (Buffer.byteLength(JSON.stringify(args), 'utf8') > 64 * 1024) {
238
+ throw inputError('arguments exceed 64 KiB');
239
+ }
240
+ for (const required of schema.required) {
241
+ if (args[required] === undefined || args[required] === null) {
242
+ throw inputError(`${required} is required`);
243
+ }
244
+ }
245
+ for (const field of Object.keys(args)) {
246
+ if (!schema.allowed.includes(field)) {
247
+ throw inputError(`Field "${field}" is not allowed for ${type}`);
248
+ }
249
+ }
250
+ assertNoAuthorityExpansion(args);
251
+ switch (type) {
252
+ case 'delivery.plan':
253
+ assertString(args.description, 'description', 8_000);
254
+ if (!Array.isArray(args.rootIds) || args.rootIds.length < 1 || args.rootIds.length > 32) {
255
+ throw inputError('rootIds must contain 1 to 32 approved roots');
256
+ }
257
+ args.rootIds.forEach(assertRootId);
258
+ assertBudget(args.budget);
259
+ break;
260
+ case 'workspace.inspect':
261
+ assertRootId(args.rootId);
262
+ if (args.includeGit !== undefined && typeof args.includeGit !== 'boolean') {
263
+ throw inputError('includeGit must be boolean');
264
+ }
265
+ break;
266
+ case 'worktree.create':
267
+ assertRootId(args.rootId);
268
+ assertString(args.branch, 'branch', 180);
269
+ assertString(args.worktreeRef, 'worktreeRef', 180);
270
+ break;
271
+ case 'git.inspect':
272
+ assertRootId(args.rootId);
273
+ assertString(args.ref, 'ref', 180, { optional: true });
274
+ if (args.includeDiffSummary !== undefined && typeof args.includeDiffSummary !== 'boolean') {
275
+ throw inputError('includeDiffSummary must be boolean');
276
+ }
277
+ break;
278
+ case 'agent.start':
279
+ assertUuid(args.agentId, 'agentId');
280
+ assertRootId(args.rootId);
281
+ assertString(args.provider, 'provider', 40);
282
+ assertString(args.model, 'model', 100);
283
+ if (!['architect', 'coder', 'reviewer', 'auditor'].includes(args.role)) {
284
+ throw inputError('role is invalid');
285
+ }
286
+ assertStructuredIntent(args.intent);
287
+ if (args.budget !== undefined) assertBudget(args.budget);
288
+ break;
289
+ case 'agent.turn':
290
+ assertUuid(args.agentSessionId, 'agentSessionId');
291
+ assertRootId(args.rootId);
292
+ assertStructuredIntent(args.intent);
293
+ if (args.budget !== undefined) assertBudget(args.budget);
294
+ break;
295
+ case 'agent.stop':
296
+ assertUuid(args.agentSessionId, 'agentSessionId');
297
+ assertRootId(args.rootId);
298
+ assertString(args.reason, 'reason', 240);
299
+ break;
300
+ case 'evidence.collect':
301
+ assertRootId(args.rootId);
302
+ if (!Array.isArray(args.kinds) || args.kinds.length < 1 || args.kinds.length > 8) {
303
+ throw inputError('kinds must contain 1 to 8 entries');
304
+ }
305
+ for (const kind of args.kinds) {
306
+ if (!['check_result', 'screenshot', 'diff_summary', 'file_manifest', 'review'].includes(kind)) {
307
+ throw inputError(`Unsupported evidence kind "${kind}"`);
308
+ }
309
+ }
310
+ if (
311
+ args.maxBytes !== undefined &&
312
+ (!Number.isInteger(args.maxBytes) || args.maxBytes < 1 || args.maxBytes > 1_048_576)
313
+ ) {
314
+ throw inputError('maxBytes is outside the allowed range');
315
+ }
316
+ break;
317
+ case 'hub.chat.post':
318
+ if (typeof args.ch !== 'string' || args.ch.length > HUB_CHANNEL_MAX || !HUB_CHANNEL.test(args.ch)) {
319
+ throw inputError('ch must be a task:, project: or dm: channel id');
320
+ }
321
+ assertString(args.text, 'text', HUB_TEXT_LIMITS.chat);
322
+ break;
323
+ case 'hub.nudge':
324
+ if (!Array.isArray(args.to) || args.to.length < 1 || args.to.length > 50) throw inputError('to must contain 1 to 50 agent ids');
325
+ for (const id of args.to) assertLocalId(id, 'to[]');
326
+ if (new Set(args.to).size !== args.to.length) throw inputError('to must not repeat an agent');
327
+ if (!['NUDGE', 'STATUS?'].includes(args.type)) throw inputError('type must be NUDGE or STATUS?');
328
+ assertString(args.text, 'text', HUB_TEXT_LIMITS.nudge);
329
+ break;
330
+ case 'hub.decision':
331
+ if (typeof args.key !== 'string' || !HUB_STREAM_KEY.test(args.key)) throw inputError('key must be a <repo>/<channel> stream key');
332
+ if (!['DECISION', 'CHANGES REQUESTED'].includes(args.kind)) throw inputError('kind must be DECISION or CHANGES REQUESTED');
333
+ assertString(args.text, 'text', HUB_TEXT_LIMITS.decision);
334
+ if (args.ref !== undefined) assertLocalId(args.ref, 'ref');
335
+ break;
336
+ case 'hub.accept':
337
+ assertLocalId(args.delivery, 'delivery', 100);
338
+ assertString(args.note, 'note', HUB_TEXT_LIMITS.acceptNote, { optional: true });
339
+ break;
340
+ case 'hub.bus.reply':
341
+ assertLocalId(args.id, 'id');
342
+ assertString(args.text, 'text', HUB_TEXT_LIMITS.busReply);
343
+ break;
344
+ case 'hub.status.set': {
345
+ const { target } = args;
346
+ if (!target || typeof target !== 'object' || Array.isArray(target) || Object.keys(target).some((key) => !['kind', 'id'].includes(key))) {
347
+ throw inputError('target must be { kind, id }');
348
+ }
349
+ if (target.kind === 'stream') {
350
+ if (typeof target.id !== 'string' || target.id.length > 240 || !HUB_STREAM_KEY.test(target.id)) throw inputError('target.id must be a <repo>/<channel> stream key');
351
+ if (!HUB_STEPS.includes(args.step)) throw inputError(`step must be one of ${HUB_STEPS.join(', ')}`);
352
+ const extra = Object.keys(args).find((key) => !['target', 'step'].includes(key));
353
+ if (extra) throw inputError(`${extra} is not part of a move`);
354
+ break;
355
+ }
356
+ if (args.step !== undefined) throw inputError('step is only for a stream');
357
+ if (!['delivery', 'agent'].includes(target.kind)) throw inputError('target.kind must be delivery, agent or stream');
358
+ if (typeof target.id !== 'string' || !HUB_TARGET_ID.test(target.id)) throw inputError('target.id must be a local hub id of at most 100 characters');
359
+ if (!HUB_STATUSES.includes(args.status)) throw inputError(`status must be one of ${HUB_STATUSES.join(', ')}`);
360
+ assertString(args.reason, 'reason', HUB_TEXT_LIMITS.statusReason, { optional: args.status !== 'blocked' });
361
+ if (args.status === 'handed-over') assertString(args.to, 'to', HUB_TEXT_LIMITS.statusTo);
362
+ else if (args.to !== undefined) throw inputError('to is only for handed-over');
363
+ break;
364
+ }
365
+ default:
366
+ assertNever(type, 'job type');
367
+ }
368
+ return structuredClone(args);
369
+ }
370
+
371
+ function expectedCapability(type, args, sessionProvider) {
372
+ switch (type) {
373
+ case 'delivery.plan':
374
+ case 'workspace.inspect':
375
+ case 'evidence.collect':
376
+ return 'workspace.read';
377
+ case 'worktree.create':
378
+ return 'worktree.create';
379
+ case 'git.inspect':
380
+ return 'git.read';
381
+ case 'agent.start':
382
+ return `agent.${args.provider}.invoke`;
383
+ case 'agent.turn':
384
+ case 'agent.stop':
385
+ if (!sessionProvider) throw inputError('session provider is required');
386
+ return `agent.${sessionProvider}.invoke`;
387
+ case 'hub.chat.post':
388
+ case 'hub.nudge':
389
+ case 'hub.decision':
390
+ case 'hub.accept':
391
+ case 'hub.bus.reply':
392
+ case 'hub.status.set':
393
+ return 'hub.write';
394
+ default:
395
+ return assertNever(type, 'job type');
396
+ }
397
+ }
398
+
399
+ export function validateIntent(job, policy, { sessionProvider = null } = {}) {
400
+ const allowedJobFields = new Set([
401
+ 'type', 'capability', 'rootId', 'arguments', 'policyVersion', 'idempotencyKey',
402
+ ]);
403
+ if (!job || typeof job !== 'object' || Array.isArray(job)) throw inputError('job must be an object');
404
+ if (Object.keys(job).some((field) => !allowedJobFields.has(field))) {
405
+ throw inputError('job contains an unsupported field');
406
+ }
407
+ if (!JOB_TYPES.includes(job.type)) throw inputError(`Unsupported job type "${job.type}"`);
408
+ if (!Number.isInteger(job.policyVersion) || job.policyVersion !== policy.version) {
409
+ throw new HubError('HUB_POLICY_STALE', 'Job policy version does not match the local grant');
410
+ }
411
+ if (
412
+ typeof job.idempotencyKey !== 'string' ||
413
+ job.idempotencyKey.length < 1 ||
414
+ job.idempotencyKey.length > 128
415
+ ) {
416
+ throw inputError('idempotencyKey is required and bounded');
417
+ }
418
+ const args = validateArguments(job.type, job.arguments);
419
+ const capability = expectedCapability(job.type, args, sessionProvider);
420
+ if (job.capability !== capability || !INTENT_KINDS.includes(capability)) {
421
+ throw new HubError('HUB_CAPABILITY_DENIED', 'Job capability does not match its registered type');
422
+ }
423
+ if (!policy.capabilities.includes(capability)) {
424
+ throw new HubError('HUB_CAPABILITY_DENIED', `Capability is not granted: ${capability}`);
425
+ }
426
+ const rootIds = args.rootIds || (args.rootId ? [args.rootId] : []);
427
+ if (rootIds.some((rootId) => !policy.rootIds.includes(rootId))) {
428
+ throw new HubError('HUB_CAPABILITY_DENIED', 'Job references an unapproved root');
429
+ }
430
+ if (job.rootId !== undefined && args.rootId !== undefined && job.rootId !== args.rootId) {
431
+ throw new HubError('HUB_CAPABILITY_DENIED', 'Job root does not match its typed arguments');
432
+ }
433
+ if (job.type === 'agent.start') {
434
+ if (!policy.providers?.[args.provider]?.includes(args.model)) {
435
+ throw new HubError('HUB_PROVIDER_MODEL_DENIED', 'Provider or model is not allowed');
436
+ }
437
+ }
438
+ return { ...structuredClone(job), arguments: args };
439
+ }
440
+
441
+ export function validateJobResult(type, result, { now = Date.now } = {}) {
442
+ const allowed = RESULT_FIELDS[type];
443
+ if (!allowed) throw inputError(`Unsupported job type "${type}"`);
444
+ if (result === null || result === undefined) return null;
445
+ if (typeof result !== 'object' || Array.isArray(result)) {
446
+ throw inputError('job result must be an object');
447
+ }
448
+ if (Buffer.byteLength(JSON.stringify(result), 'utf8') > 64 * 1024) {
449
+ throw inputError('job result exceeds 64 KiB');
450
+ }
451
+ for (const key of Object.keys(result)) {
452
+ if (!allowed.includes(key)) throw inputError(`Field "${key}" is not allowed in ${type} result`);
453
+ }
454
+ assertSafeCloudValue(result);
455
+ if (typeof result.summary === 'string' && result.summary.length > 8_000) {
456
+ throw inputError('result summary exceeds 8000 characters');
457
+ }
458
+ if (HUB_JOB_TYPES.includes(type)) {
459
+ if (!HUB_OUTCOMES.includes(result.outcome)) throw inputError(`hub outcome must be one of ${HUB_OUTCOMES.join(', ')}`);
460
+ assertString(result.summary, 'hub summary', 500);
461
+ }
462
+ if (type === 'agent.turn' && result.needsUser === true) {
463
+ if (!result.decision || typeof result.decision !== 'object' || Array.isArray(result.decision)) {
464
+ throw inputError('needsUser result requires a structured decision');
465
+ }
466
+ if (Object.keys(result.decision).some((key) => !['summary', 'options'].includes(key))) {
467
+ throw inputError('decision contains an unsupported field');
468
+ }
469
+ assertString(result.decision.summary, 'decision summary', 1_000);
470
+ if (
471
+ !Array.isArray(result.decision.options) ||
472
+ result.decision.options.length < 1 ||
473
+ result.decision.options.length > 10
474
+ ) {
475
+ throw inputError('decision options must contain 1 to 10 entries');
476
+ }
477
+ for (const option of result.decision.options) {
478
+ if (
479
+ !option ||
480
+ typeof option !== 'object' ||
481
+ Array.isArray(option) ||
482
+ Object.keys(option).some((key) => !['id', 'label', 'impact'].includes(key))
483
+ ) {
484
+ throw inputError('decision option contains an unsupported field');
485
+ }
486
+ assertString(option.id, 'decision option id', 80);
487
+ assertString(option.label, 'decision option label', 240);
488
+ assertString(option.impact, 'decision option impact', 1_000, { optional: true });
489
+ }
490
+ }
491
+ if (type === 'evidence.collect') {
492
+ if (!Array.isArray(result.artifacts) || result.artifacts.length < 1 || result.artifacts.length > 20) {
493
+ throw inputError('evidence artifacts must contain 1 to 20 entries');
494
+ }
495
+ const allowedArtifactFields = new Set([
496
+ 'kind', 'label', 'mimeType', 'byteSize', 'sha256', 'storageMode',
497
+ 'contentText', 'reference', 'expiresAt', 'redactionVersion', 'userVisible',
498
+ ]);
499
+ for (const artifact of result.artifacts) {
500
+ if (
501
+ !artifact ||
502
+ typeof artifact !== 'object' ||
503
+ Array.isArray(artifact) ||
504
+ Object.keys(artifact).some((key) => !allowedArtifactFields.has(key))
505
+ ) {
506
+ throw inputError('artifact contains an unsupported field');
507
+ }
508
+ if (!ARTIFACT_KINDS.has(artifact.kind)) throw inputError('artifact kind is invalid');
509
+ assertString(artifact.label, 'artifact label', 180);
510
+ assertString(artifact.mimeType, 'artifact mimeType', 100);
511
+ if (!Number.isInteger(artifact.byteSize) || artifact.byteSize < 0 || artifact.byteSize > 1_048_576) {
512
+ throw inputError('artifact byteSize is invalid');
513
+ }
514
+ if (!SHA256.test(String(artifact.sha256 || ''))) throw inputError('artifact sha256 is invalid');
515
+ if (!ARTIFACT_STORAGE.has(artifact.storageMode)) throw inputError('artifact storageMode is invalid');
516
+ if (!Number.isInteger(artifact.redactionVersion) || artifact.redactionVersion < 1) {
517
+ throw inputError('artifact redactionVersion is invalid');
518
+ }
519
+ if (artifact.userVisible !== true) throw inputError('artifact must be explicitly user visible');
520
+ if (
521
+ artifact.contentText !== undefined &&
522
+ (typeof artifact.contentText !== 'string' || artifact.contentText.length > 32_000)
523
+ ) {
524
+ throw inputError('artifact contentText is invalid');
525
+ }
526
+ if (artifact.storageMode === 'inline_redacted') {
527
+ if (artifact.contentText === undefined || artifact.reference !== undefined) {
528
+ throw inputError('inline artifact requires only redacted contentText');
529
+ }
530
+ }
531
+ if (artifact.storageMode === 'signed_reference') {
532
+ if (
533
+ artifact.contentText !== undefined ||
534
+ !artifact.reference ||
535
+ typeof artifact.reference !== 'object' ||
536
+ Array.isArray(artifact.reference) ||
537
+ Object.keys(artifact.reference).some(
538
+ (key) => !['referenceId', 'expiresAt'].includes(key)
539
+ )
540
+ ) {
541
+ throw inputError('signed-reference artifact requires only a structured reference');
542
+ }
543
+ assertString(artifact.reference.referenceId, 'artifact referenceId', 128);
544
+ assertString(artifact.reference.expiresAt, 'artifact reference expiresAt', 40);
545
+ assertString(artifact.expiresAt, 'artifact expiresAt', 40);
546
+ const referenceExpiry = Date.parse(artifact.reference.expiresAt);
547
+ const artifactExpiry = Date.parse(artifact.expiresAt);
548
+ const currentTime = now();
549
+ if (
550
+ !Number.isFinite(referenceExpiry) ||
551
+ !Number.isFinite(artifactExpiry) ||
552
+ referenceExpiry !== artifactExpiry ||
553
+ referenceExpiry <= currentTime ||
554
+ referenceExpiry > currentTime + 15 * 60 * 1_000
555
+ ) {
556
+ throw inputError('signed artifact expiry must match and be within 15 minutes');
557
+ }
558
+ }
559
+ if (
560
+ artifact.storageMode === 'local_only' &&
561
+ (artifact.contentText !== undefined || artifact.reference !== undefined)
562
+ ) {
563
+ throw inputError('local-only artifact cannot upload content or a reference');
564
+ }
565
+ }
566
+ }
567
+ return structuredClone(result);
568
+ }
569
+
570
+ export class IntentRegistry {
571
+ constructor({ handlers, policy }) {
572
+ this.handlers = new Map();
573
+ this.policy = structuredClone(policy);
574
+ for (const [type, handler] of Object.entries(handlers)) {
575
+ if (!JOB_TYPES.includes(type) || typeof handler !== 'function') {
576
+ throw new HubError('HUB_INTENT_HANDLER_DENIED', `Invalid job handler: ${type}`);
577
+ }
578
+ this.handlers.set(type, handler);
579
+ }
580
+ }
581
+
582
+ async execute(job, context = {}) {
583
+ const validated = validateIntent(job, this.policy, context);
584
+ const handler = this.handlers.get(validated.type);
585
+ if (!handler) {
586
+ throw new HubError('HUB_INTENT_HANDLER_MISSING', `No local handler is registered for ${validated.type}`);
587
+ }
588
+ return handler(validated, context);
589
+ }
590
+ }