@agentpulselabs/cli-plugin-pulse 0.2.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 (69) hide show
  1. package/README.md +69 -32
  2. package/package.json +10 -18
  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 +6 -18
  11. package/src/commands/pulse/status.js +6 -6
  12. package/src/commands/pulse/stop.js +24 -0
  13. package/src/daemon/main.js +36 -0
  14. package/src/lib/BaseCommand.js +23 -23
  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/LICENSE +0 -21
  65. package/src/commands/pulse/gate.js +0 -25
  66. package/src/commands/pulse/next.js +0 -14
  67. package/src/commands/pulse/submit.js +0 -21
  68. package/src/commands/pulse/verify.js +0 -21
  69. package/src/runPulse.js +0 -58
@@ -0,0 +1,85 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { HubError } from './errors.js';
3
+
4
+ function assertLimit(value, limit, code, label) {
5
+ if (!Number.isFinite(value) || value < 0) {
6
+ throw new HubError(code, `${label} usage must be a finite non-negative number`);
7
+ }
8
+ if (value > limit) {
9
+ throw new HubError(code, `${label} budget exceeded (${value} > ${limit})`);
10
+ }
11
+ }
12
+
13
+ export class BudgetGuard {
14
+ #active = new Map();
15
+
16
+ constructor({
17
+ maxTokens,
18
+ maxCostUsd,
19
+ maxWallMs,
20
+ maxOutputBytes,
21
+ maxConcurrency,
22
+ now = Date.now,
23
+ }) {
24
+ for (const [name, value] of Object.entries({
25
+ maxTokens,
26
+ maxCostUsd,
27
+ maxWallMs,
28
+ maxOutputBytes,
29
+ maxConcurrency,
30
+ })) {
31
+ if (!Number.isFinite(value) || value <= 0 || (name === 'maxConcurrency' && !Number.isInteger(value))) {
32
+ throw new HubError('HUB_BUDGET_INVALID', `${name} must be an explicit positive finite limit`);
33
+ }
34
+ }
35
+ this.limits = { maxTokens, maxCostUsd, maxWallMs, maxOutputBytes, maxConcurrency };
36
+ this.now = now;
37
+ }
38
+
39
+ acquire() {
40
+ assertLimit(
41
+ this.#active.size + 1,
42
+ this.limits.maxConcurrency,
43
+ 'HUB_BUDGET_CONCURRENCY',
44
+ 'Concurrency'
45
+ );
46
+ const startedAt = this.now();
47
+ if (!Number.isFinite(startedAt) || startedAt < 0) {
48
+ throw new HubError('HUB_BUDGET_TIME', 'Monotonic clock returned an invalid value');
49
+ }
50
+ const lease = { id: randomUUID(), startedAt };
51
+ this.#active.set(lease.id, { ...lease, tokens: 0, costUsd: 0, outputBytes: 0 });
52
+ return { ...lease };
53
+ }
54
+
55
+ assertWithin({ id, tokens, costUsd, outputBytes }) {
56
+ const lease = this.#active.get(id);
57
+ if (!lease) throw new HubError('HUB_BUDGET_LEASE_INVALID', 'Budget lease is not active');
58
+ assertLimit(tokens, this.limits.maxTokens, 'HUB_BUDGET_TOKENS', 'Token');
59
+ assertLimit(costUsd, this.limits.maxCostUsd, 'HUB_BUDGET_COST', 'Cost');
60
+ assertLimit(outputBytes, this.limits.maxOutputBytes, 'HUB_BUDGET_OUTPUT', 'Output');
61
+ assertLimit(this.now() - lease.startedAt, this.limits.maxWallMs, 'HUB_BUDGET_TIME', 'Wall time');
62
+ }
63
+
64
+ consume(lease, delta) {
65
+ const current = this.#active.get(lease.id);
66
+ if (!current) throw new HubError('HUB_BUDGET_LEASE_INVALID', 'Budget lease is not active');
67
+ const next = {
68
+ id: current.id,
69
+ tokens: current.tokens + delta.tokens,
70
+ costUsd: current.costUsd + delta.costUsd,
71
+ outputBytes: current.outputBytes + delta.outputBytes,
72
+ };
73
+ this.assertWithin(next);
74
+ Object.assign(current, next);
75
+ return { ...current };
76
+ }
77
+
78
+ release(lease) {
79
+ this.#active.delete(lease.id);
80
+ }
81
+
82
+ get active() {
83
+ return this.#active.size;
84
+ }
85
+ }
@@ -0,0 +1,20 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ export async function readCliDefaults({ env = process.env, home = os.homedir() } = {}) {
6
+ const file = path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'agentpulse', 'config.json');
7
+ let config;
8
+ try {
9
+ config = JSON.parse(await readFile(file, 'utf8'));
10
+ } catch {
11
+ return {};
12
+ }
13
+ const aliases = Object.keys(config?.profiles || {});
14
+ const alias = config?.profiles?.[config.defaultProfile] ? config.defaultProfile : aliases.length === 1 ? aliases[0] : null;
15
+ const profile = alias ? config.profiles[alias] : null;
16
+ return {
17
+ platformUrl: profile?.baseUrl || profile?.url || null,
18
+ devOrgSlug: profile?.devOrgSlug || null,
19
+ };
20
+ }
@@ -0,0 +1,189 @@
1
+ import { HubError } from './errors.js';
2
+ import { cleanOrigin, decodeJwtClaims, fetchJson } from './http.js';
3
+ import { DEV_ORG_INGRESS_HEADER, OAUTH_CLIENT_ID } from './transportContract.js';
4
+
5
+ const SKEW_MS = 60_000;
6
+ const TOKEN_TIMEOUT_MS = 15_000;
7
+
8
+ export function loginRequired(reason) {
9
+ return new HubError(
10
+ 'HUB_LOGIN_REQUIRED',
11
+ `Pulse connector sign-in is missing or expired (${reason}). Run \`agentpulse pulse login\`, then \`agentpulse pulse start\`.`,
12
+ );
13
+ }
14
+
15
+ function refreshUnavailable(reason, cause) {
16
+ return new HubError(
17
+ 'HUB_REFRESH_UNAVAILABLE',
18
+ `Could not refresh the Pulse sign-in (${reason}); the connector will retry and claims no work until it succeeds`,
19
+ cause ? { cause } : {},
20
+ );
21
+ }
22
+
23
+ function expiresSoon(expiresAt, now) {
24
+ const expiry = Date.parse(expiresAt || '');
25
+ return !Number.isFinite(expiry) || expiry - now() <= SKEW_MS;
26
+ }
27
+
28
+ export class CloudAuth {
29
+ constructor({ tokenStore, target, fetch = globalThis.fetch, now = Date.now }) {
30
+ this.tokenStore = tokenStore;
31
+ this.target = {
32
+ ...target,
33
+ platformUrl: cleanOrigin(target.platformUrl, 'Platform URL'),
34
+ instanceUrl: target.instanceUrl ? cleanOrigin(target.instanceUrl, 'Instance URL') : null,
35
+ };
36
+ this.fetch = fetch;
37
+ this.now = now;
38
+ this.devOrgSession = null;
39
+ this.platformSession = null;
40
+ this.forceRefresh = false;
41
+ }
42
+
43
+ get instanceUrl() {
44
+ return this.target.devOrg ? this.target.instanceUrl : this.target.platformUrl;
45
+ }
46
+
47
+ invalidate() {
48
+ this.devOrgSession = null;
49
+ this.platformSession = null;
50
+ if (!this.target.devOrg) this.forceRefresh = true;
51
+ }
52
+
53
+ async #refresh(session) {
54
+ if (!session.refreshToken) throw loginRequired('access token expired and no refresh token is stored');
55
+ let refreshed;
56
+ try {
57
+ refreshed = await fetchJson({
58
+ fetch: this.fetch,
59
+ url: `${this.target.platformUrl}/api/oauth/token`,
60
+ body: { grant_type: 'refresh_token', client_id: OAUTH_CLIENT_ID, refresh_token: session.refreshToken },
61
+ timeoutMs: TOKEN_TIMEOUT_MS,
62
+ });
63
+ } catch (error) {
64
+ throw refreshUnavailable('the platform did not answer', error);
65
+ }
66
+ if (refreshed.status >= 500 || refreshed.status === 429) {
67
+ throw refreshUnavailable(`the platform answered HTTP ${refreshed.status}`);
68
+ }
69
+ if (refreshed.body?.error === 'invalid_grant') {
70
+ throw loginRequired('the platform rejected or revoked the refresh token (invalid_grant)');
71
+ }
72
+ const claims = decodeJwtClaims(refreshed.body?.access_token);
73
+ if (
74
+ !refreshed.ok ||
75
+ claims?.type !== 'user_delegated' ||
76
+ claims.sub !== session.userId ||
77
+ claims.team_id !== session.teamId
78
+ ) {
79
+ throw loginRequired('token refresh did not return this user\'s delegated token');
80
+ }
81
+ const expiresIn = Number(refreshed.body.expires_in);
82
+ const next = {
83
+ ...session,
84
+ accessToken: refreshed.body.access_token,
85
+ refreshToken: typeof refreshed.body.refresh_token === 'string' ? refreshed.body.refresh_token : session.refreshToken,
86
+ expiresAt: new Date(this.now() + (Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600) * 1000).toISOString(),
87
+ };
88
+ await this.tokenStore.save(next);
89
+ return next;
90
+ }
91
+
92
+ async #storedSession() {
93
+ const session = await this.tokenStore.load();
94
+ if (!session?.accessToken) throw loginRequired('no stored sign-in');
95
+ if (
96
+ session.platformUrl !== this.target.platformUrl ||
97
+ session.userId !== this.target.userId ||
98
+ session.teamId !== this.target.teamId
99
+ ) {
100
+ throw loginRequired('the stored sign-in belongs to a different platform, team or user');
101
+ }
102
+ return session;
103
+ }
104
+
105
+ // The stored session is in the OS secret store (a Keychain read is 0.5-2 s), so it is read once and kept until 60 s
106
+ // before expiry or until invalidate().
107
+ async #platformSession() {
108
+ if (!this.forceRefresh && this.platformSession && !expiresSoon(this.platformSession.expiresAt, this.now)) {
109
+ return this.platformSession;
110
+ }
111
+ this.platformSession = await this.#loadPlatformSession();
112
+ return this.platformSession;
113
+ }
114
+
115
+ async #loadPlatformSession() {
116
+ const session = await this.#storedSession();
117
+ if (!this.forceRefresh && !expiresSoon(session.expiresAt, this.now)) return session;
118
+ const rejectedAccessToken = this.forceRefresh ? session.accessToken : null;
119
+ const current = await this.tokenStore.withRefreshLock(async () => {
120
+ const latest = await this.#storedSession();
121
+ const refreshedElsewhere = rejectedAccessToken && latest.accessToken !== rejectedAccessToken;
122
+ if (refreshedElsewhere || (!rejectedAccessToken && !expiresSoon(latest.expiresAt, this.now))) return latest;
123
+ return this.#refresh(latest);
124
+ });
125
+ this.forceRefresh = false;
126
+ return current;
127
+ }
128
+
129
+ async #mintDevOrgSession(platformSession) {
130
+ let minted;
131
+ try {
132
+ minted = await fetchJson({
133
+ fetch: this.fetch,
134
+ url: `${this.target.platformUrl}/api/team/dev-orgs/${encodeURIComponent(this.target.devOrg.id)}/cli-token`,
135
+ headers: { authorization: `Bearer ${platformSession.accessToken}` },
136
+ body: {},
137
+ timeoutMs: TOKEN_TIMEOUT_MS,
138
+ });
139
+ } catch (error) {
140
+ throw new HubError('HUB_TARGET_UNAVAILABLE', 'Could not reach the platform to connect to the dev-org', { cause: error });
141
+ }
142
+ if ([401, 403].includes(minted.status) || minted.body?.code === 'NO_USER_IDENTITY') {
143
+ throw loginRequired(`the platform refused the dev-org connection (HTTP ${minted.status})`);
144
+ }
145
+ if (!minted.ok) {
146
+ throw new HubError(
147
+ 'HUB_TARGET_UNAVAILABLE',
148
+ `Dev-org ${this.target.devOrg.slug} is not reachable (HTTP ${minted.status}${minted.body?.code ? ` ${minted.body.code}` : ''})`,
149
+ );
150
+ }
151
+ const claims = decodeJwtClaims(minted.body.token);
152
+ if (claims?.userId !== this.target.userId || claims?.teamId !== this.target.teamId) {
153
+ throw loginRequired('the dev-org token was minted for a different user');
154
+ }
155
+ const instanceUrl = cleanOrigin(minted.body.instanceUrl, 'Dev-org instance URL');
156
+ if (this.target.instanceUrl && instanceUrl !== this.target.instanceUrl) {
157
+ throw new HubError(
158
+ 'HUB_TARGET_CHANGED',
159
+ `Dev-org ${this.target.devOrg.slug} moved to a new address; pair this machine again`,
160
+ );
161
+ }
162
+ this.target.instanceUrl = instanceUrl;
163
+ return {
164
+ token: minted.body.token,
165
+ identityToken: typeof minted.body.identityToken === 'string' ? minted.body.identityToken : null,
166
+ expiresAt: minted.body.expiresAt,
167
+ };
168
+ }
169
+
170
+ /** The credentials held in memory now, so the mirror can redact them by identity; it never reads the secret store. */
171
+ heldSecrets() {
172
+ return [this.platformSession?.accessToken, this.platformSession?.refreshToken, this.devOrgSession?.token, this.devOrgSession?.identityToken]
173
+ .filter((value) => typeof value === 'string' && value.length >= 16);
174
+ }
175
+
176
+ async credentials() {
177
+ const platformSession = await this.#platformSession();
178
+ if (!this.target.devOrg) return { authorization: `Bearer ${platformSession.accessToken}` };
179
+ if (!this.devOrgSession || expiresSoon(this.devOrgSession.expiresAt, this.now)) {
180
+ this.devOrgSession = await this.#mintDevOrgSession(platformSession);
181
+ }
182
+ return {
183
+ authorization: `Bearer ${this.devOrgSession.token}`,
184
+ ...(this.devOrgSession.identityToken
185
+ ? { [DEV_ORG_INGRESS_HEADER]: `Bearer ${this.devOrgSession.identityToken}` }
186
+ : {}),
187
+ };
188
+ }
189
+ }
@@ -0,0 +1,2 @@
1
+ 88e39539d7d2b1775b4970a8aeed416ae6d7203c53df143093ea54d2014cdd31 connector-contract.json
2
+ 7b7a59c08797c100b46436123dfcb09b517730357fa90cd0890da9b80308faf3 mirror-contract.json
@@ -0,0 +1,338 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { AgentSessionStore } from './agentSessions.js';
3
+ import { CloudAuth } from './cloudAuth.js';
4
+ import { defaultSecretStore } from './secretStore.js';
5
+ import { HubError } from './errors.js';
6
+ import { createHubActionHandlers } from './hubActions.js';
7
+ import { discoverLocalHub } from './localHub.js';
8
+ import { LocalHubWriter } from './localHubWriter.js';
9
+ import { assertPairedConfig } from './pairedConfig.js';
10
+ import { canonicalJson, validateDeliveryPlan } from './planContract.js';
11
+ import { TokenStore } from './tokenStore.js';
12
+ import { ConnectorTransport } from './transport.js';
13
+ import { validateRootGrant, WorktreeRegistry, createOwnedWorktree } from './rootPolicy.js';
14
+ import { execFileBounded as execFile } from './subprocess.js';
15
+
16
+ export async function createProductionDependencies({
17
+ config,
18
+ configDir,
19
+ secretStore = defaultSecretStore(),
20
+ fetch = globalThis.fetch,
21
+ log = () => {},
22
+ onTransportAttempt = () => {},
23
+ }) {
24
+ assertPairedConfig(config);
25
+ const tokenStore = new TokenStore({ secretStore, configDir });
26
+ const sessionId = randomUUID();
27
+ const worktrees = await WorktreeRegistry.open(configDir);
28
+ const sessions = await AgentSessionStore.open(configDir);
29
+ const roots = new Map((config.localRoots || []).map((root) => [root.rootId, root]));
30
+ let transport;
31
+ let transportKey;
32
+
33
+ const transportFactory = async (currentConfig) => {
34
+ const paired = assertPairedConfig(currentConfig);
35
+ const key = [paired.machineId, paired.policyVersion, paired.baseUrl, paired.userId].join('\0');
36
+ if (transport && transportKey === key) return transport;
37
+ const auth = new CloudAuth({
38
+ tokenStore,
39
+ fetch,
40
+ target: {
41
+ platformUrl: paired.target.platformUrl,
42
+ devOrg: paired.target.devOrg,
43
+ instanceUrl: paired.baseUrl,
44
+ userId: paired.userId,
45
+ teamId: paired.teamId,
46
+ },
47
+ });
48
+ transport = new ConnectorTransport({
49
+ baseUrl: paired.baseUrl,
50
+ machine: { machineId: paired.machineId, sessionId, policyVersion: paired.policyVersion },
51
+ auth,
52
+ fetch,
53
+ onAttempt: onTransportAttempt,
54
+ });
55
+ transportKey = key;
56
+ return transport;
57
+ };
58
+
59
+ const hubActions = createHubActionHandlers({
60
+ writerFor: async () => {
61
+ let hub;
62
+ try {
63
+ hub = await discoverLocalHub({ config });
64
+ } catch (error) {
65
+ throw new HubError('hub_unreachable', error.message);
66
+ }
67
+ return new LocalHubWriter({ ...hub, fetch, log });
68
+ },
69
+ });
70
+
71
+ const handlersFactory = ({ drivers, policy }) => {
72
+ const plannerChoices = Object.entries(policy.providers).flatMap(([provider, models]) =>
73
+ models.map((model) => ({ provider, model }))
74
+ );
75
+ const defaultProvider = plannerChoices.length === 1 ? plannerChoices[0].provider : null;
76
+ const defaultModel = plannerChoices.length === 1 ? plannerChoices[0].model : null;
77
+ const budget = (requested = {}) => ({
78
+ maxTokens: requested.maxTokens || policy.limits.maxTokensPerRun,
79
+ maxCostUsd: requested.maxCostUsd ?? policy.limits.maxCostUsd,
80
+ maxWallMs: requested.maxWallMs || policy.limits.maxWallMs,
81
+ maxOutputBytes: requested.maxOutputBytes || policy.limits.maxOutputBytes,
82
+ });
83
+ const workspaceFor = (rootId, mode, cloudJob = {}, expectedWorktreeId = null) => {
84
+ const root = roots.get(rootId);
85
+ if (!root) throw new HubError('HUB_CAPABILITY_DENIED', 'Root is not locally approved');
86
+ if (mode === 'read') return root.canonicalRoot;
87
+ const candidates = worktrees
88
+ .list(rootId)
89
+ .filter(
90
+ (record) =>
91
+ record.deliveryId === cloudJob.delivery_id &&
92
+ record.streamId === cloudJob.stream_id
93
+ );
94
+ if (candidates.length !== 1) {
95
+ throw new HubError(
96
+ 'HUB_WRITE_REQUIRES_WORKTREE',
97
+ 'Write-mode agent requires exactly one connector-owned worktree'
98
+ );
99
+ }
100
+ if (expectedWorktreeId && candidates[0].worktreeId !== expectedWorktreeId) {
101
+ throw new HubError(
102
+ 'HUB_AGENT_SESSION_BINDING_MISMATCH',
103
+ 'Continuation worktree does not match its durable local agent binding'
104
+ );
105
+ }
106
+ return candidates[0].canonicalPath;
107
+ };
108
+ return {
109
+ 'delivery.plan': async (job, context) => {
110
+ if (!defaultProvider || !defaultModel) {
111
+ throw new HubError(
112
+ 'HUB_PROVIDER_DENIED',
113
+ 'Planner requires exactly one signed provider/model assignment'
114
+ );
115
+ }
116
+ const rootId = job.arguments.rootIds[0];
117
+ const result = await drivers.driver(defaultProvider, defaultModel).start({
118
+ workspace: workspaceFor(rootId, 'read', context.cloudJob),
119
+ provider: defaultProvider,
120
+ model: defaultModel,
121
+ role: 'architect',
122
+ mode: 'read',
123
+ prompt: `Produce only a bounded JSON delivery plan for this approved outcome:\n${job.arguments.description}`,
124
+ budget: budget(job.arguments.budget),
125
+ attempt: context.attempt,
126
+ onStart: context.onProcessStart,
127
+ signal: context.signal,
128
+ });
129
+ let plan;
130
+ try {
131
+ plan = JSON.parse(result.text);
132
+ } catch {
133
+ throw new HubError('HUB_PROVIDER_OUTPUT_INVALID', 'Planner did not return JSON');
134
+ }
135
+ const validatedPlan = validateDeliveryPlan(plan, grant);
136
+ return {
137
+ plan: validatedPlan,
138
+ planDigest: createHash('sha256').update(canonicalJson(validatedPlan)).digest('hex'),
139
+ };
140
+ },
141
+ 'workspace.inspect': async (job) => {
142
+ const root = roots.get(job.arguments.rootId);
143
+ if (!root) throw new HubError('HUB_CAPABILITY_DENIED', 'Root is not locally approved');
144
+ await validateRootGrant(root);
145
+ return {
146
+ summary: 'Approved workspace root is available.',
147
+ repo: { rootId: root.rootId, repoWrite: root.repoWrite },
148
+ fileManifestHash: createHash('sha256').update(root.repositoryIdentity).digest('hex'),
149
+ };
150
+ },
151
+ 'worktree.create': async (job, context) => {
152
+ const root = roots.get(job.arguments.rootId);
153
+ if (!root) throw new HubError('HUB_CAPABILITY_DENIED', 'Root is not locally approved');
154
+ const record = await createOwnedWorktree({
155
+ grant: root,
156
+ deliveryId: context.cloudJob.delivery_id,
157
+ streamId: context.cloudJob.stream_id,
158
+ baseRef: root.baseRef,
159
+ registry: worktrees,
160
+ });
161
+ return {
162
+ summary: 'Connector-owned worktree created.',
163
+ branch: record.branch,
164
+ worktreeRef: record.worktreeId,
165
+ repoHash: root.repositoryIdentity,
166
+ };
167
+ },
168
+ 'git.inspect': async (job) => {
169
+ const root = roots.get(job.arguments.rootId);
170
+ if (!root) throw new HubError('HUB_CAPABILITY_DENIED', 'Root is not locally approved');
171
+ await validateRootGrant(root);
172
+ const env = {
173
+ PATH: process.env.PATH || '/usr/bin:/bin',
174
+ LC_ALL: 'C',
175
+ GIT_CONFIG_GLOBAL: '/dev/null',
176
+ GIT_CONFIG_SYSTEM: '/dev/null',
177
+ GIT_TERMINAL_PROMPT: '0',
178
+ };
179
+ const status = await execFile(
180
+ 'git',
181
+ ['status', '--porcelain=v1', '--untracked-files=no'],
182
+ { cwd: root.canonicalRoot, env, timeout: 30_000, maxBuffer: 512 * 1024 }
183
+ );
184
+ let refSha = null;
185
+ let diffSummary = '';
186
+ if (job.arguments.ref) {
187
+ const resolved = await execFile(
188
+ 'git',
189
+ ['rev-parse', '--verify', '--end-of-options', `${job.arguments.ref}^{commit}`],
190
+ { cwd: root.canonicalRoot, env, timeout: 30_000, maxBuffer: 64 * 1024 }
191
+ );
192
+ refSha = resolved.stdout.trim();
193
+ if (job.arguments.includeDiffSummary) {
194
+ const diff = await execFile(
195
+ 'git',
196
+ ['diff', '--stat', '--no-ext-diff', `${refSha}..HEAD`],
197
+ { cwd: root.canonicalRoot, env, timeout: 30_000, maxBuffer: 512 * 1024 }
198
+ );
199
+ diffSummary = diff.stdout;
200
+ }
201
+ }
202
+ return {
203
+ summary: 'Git metadata inspected under the approved root.',
204
+ status: {
205
+ clean: status.stdout.length === 0,
206
+ changedEntries: status.stdout.trim() ? status.stdout.trim().split('\n').length : 0,
207
+ refSha,
208
+ },
209
+ diffSummaryHash: createHash('sha256').update(diffSummary).digest('hex'),
210
+ };
211
+ },
212
+ 'agent.start': async (job, context) => {
213
+ const args = job.arguments;
214
+ const mode = args.role === 'coder' ? 'write' : 'read';
215
+ const result = await drivers.driver(args.provider, args.model).start({
216
+ workspace: workspaceFor(args.rootId, mode, context.cloudJob),
217
+ model: args.model,
218
+ role: args.role,
219
+ mode,
220
+ prompt: `Execute this approved structured intent only:\n${JSON.stringify(args.intent)}`,
221
+ budget: budget(args.budget),
222
+ attempt: context.attempt,
223
+ onStart: context.onProcessStart,
224
+ signal: context.signal,
225
+ });
226
+ await sessions.recordPendingStart(context.cloudJob.id, {
227
+ agentId: args.agentId,
228
+ provider: args.provider,
229
+ model: args.model,
230
+ role: args.role,
231
+ mode,
232
+ rootId: args.rootId,
233
+ deliveryId: context.cloudJob.delivery_id,
234
+ streamId: context.cloudJob.stream_id,
235
+ worktreeId:
236
+ mode === 'write'
237
+ ? worktrees
238
+ .list(args.rootId)
239
+ .find(
240
+ (record) =>
241
+ record.deliveryId === context.cloudJob.delivery_id &&
242
+ record.streamId === context.cloudJob.stream_id
243
+ )?.worktreeId
244
+ : null,
245
+ providerSessionId: result.sessionId,
246
+ processIdentity: result.processIdentity,
247
+ state: 'active',
248
+ });
249
+ return {
250
+ summary: drivers.driver(args.provider, args.model).collectSummary(result).text,
251
+ agentSessionRefHash: createHash('sha256')
252
+ .update(String(result.sessionId))
253
+ .digest('hex'),
254
+ };
255
+ },
256
+ 'agent.turn': async (job, context) => {
257
+ const args = job.arguments;
258
+ const session = context.sessionBinding?.session;
259
+ if (!session) throw new HubError('HUB_AGENT_SESSION_MISSING', 'Agent session is not local');
260
+ await sessions.put(args.agentSessionId, session);
261
+ const result = await drivers.driver(session.provider, session.model).send({
262
+ workspace: workspaceFor(
263
+ args.rootId,
264
+ session.mode,
265
+ context.cloudJob,
266
+ session.worktreeId
267
+ ),
268
+ model: session.model,
269
+ role: session.role,
270
+ mode: session.mode,
271
+ sessionId: session.providerSessionId,
272
+ prompt: `Execute this approved structured intent only:\n${JSON.stringify(args.intent)}`,
273
+ budget: budget(args.budget),
274
+ attempt: context.attempt,
275
+ onStart: context.onProcessStart,
276
+ signal: context.signal,
277
+ });
278
+ await sessions.put(args.agentSessionId, {
279
+ ...session,
280
+ providerSessionId: result.sessionId,
281
+ processIdentity: result.processIdentity,
282
+ });
283
+ const summary = drivers.driver(session.provider, session.model).collectSummary(result);
284
+ return {
285
+ summary: summary.text,
286
+ usage: result.usage,
287
+ evidenceIds: [],
288
+ needsUser: false,
289
+ };
290
+ },
291
+ 'agent.stop': async (job, context) => {
292
+ const args = job.arguments;
293
+ const session = context.sessionBinding?.session;
294
+ if (!session) throw new HubError('HUB_AGENT_SESSION_MISSING', 'Agent session is not local');
295
+ await sessions.put(args.agentSessionId, session);
296
+ if (session.processIdentity) {
297
+ const outcome = await drivers
298
+ .driver(session.provider, session.model)
299
+ .cancel(session.processIdentity);
300
+ if (!outcome.verifiedStopped) {
301
+ throw new HubError('HUB_CANCELLATION_UNPROVEN', 'Agent process did not stop');
302
+ }
303
+ }
304
+ await sessions.put(args.agentSessionId, { ...session, state: 'stopped' });
305
+ return { summary: args.reason, stopped: true };
306
+ },
307
+ 'evidence.collect': async (job) => ({
308
+ summary: 'Evidence remains local until explicitly approved for upload.',
309
+ artifacts: job.arguments.kinds.map((kind) => ({
310
+ kind,
311
+ label: `Local ${kind.replaceAll('_', ' ')}`,
312
+ mimeType: 'application/octet-stream',
313
+ byteSize: 0,
314
+ sha256: createHash('sha256').update('').digest('hex'),
315
+ storageMode: 'local_only',
316
+ redactionVersion: 1,
317
+ userVisible: true,
318
+ })),
319
+ }),
320
+ ...hubActions,
321
+ };
322
+ };
323
+
324
+ const sessionResolver = (job) => sessions.resolveContinuation(job);
325
+ const settlementObserver = (attempt, response, error = null) => {
326
+ if (attempt.jobType !== 'agent.start') return null;
327
+ if (error) return sessions.rejectStartSettlement(attempt.jobId);
328
+ return sessions.applyStartAcknowledgement(attempt.jobId, response);
329
+ };
330
+
331
+ return {
332
+ tokenStore,
333
+ transportFactory,
334
+ handlersFactory,
335
+ sessionResolver,
336
+ settlementObserver,
337
+ };
338
+ }
@@ -0,0 +1,55 @@
1
+ import path from 'node:path';
2
+ import { HubError } from './errors.js';
3
+ import { acquireOwnerLock } from './ownerLock.js';
4
+ import { ProcessIdentityProvider } from './processOwnership.js';
5
+ import { ensureOwnerOnlyDirectory, writeOwnerOnlyJson } from './storage.js';
6
+
7
+ export class DaemonLock {
8
+ constructor({
9
+ directory,
10
+ pid = process.pid,
11
+ identityProvider = new ProcessIdentityProvider(),
12
+ }) {
13
+ this.directory = directory;
14
+ this.pid = pid;
15
+ this.identityProvider = identityProvider;
16
+ this.pidFile = path.join(directory, 'daemon.pid');
17
+ this.healthFile = path.join(directory, 'daemon-health.json');
18
+ this.acquired = false;
19
+ }
20
+
21
+ async acquire() {
22
+ await ensureOwnerOnlyDirectory(this.directory);
23
+ this.releaseLock = await acquireOwnerLock({
24
+ file: this.pidFile,
25
+ identity: await this.identityProvider.capture(this.pid),
26
+ identityProvider: this.identityProvider,
27
+ heldError: (owner) => new HubError(
28
+ 'HUB_DAEMON_ALREADY_RUNNING',
29
+ `Pulse daemon is already running${owner?.pid ? ` with pid ${owner.pid}` : ''}`
30
+ ),
31
+ });
32
+ this.acquired = true;
33
+ return { pid: this.pid };
34
+ }
35
+
36
+ heartbeat(status) {
37
+ if (!this.acquired) {
38
+ throw new HubError('HUB_DAEMON_NOT_OWNER', 'Daemon lock is not held by this process');
39
+ }
40
+ return writeOwnerOnlyJson(this.healthFile, {
41
+ pid: this.pid,
42
+ heartbeatAt: new Date().toISOString(),
43
+ ...status,
44
+ });
45
+ }
46
+
47
+ async release() {
48
+ if (!this.acquired) return;
49
+ try {
50
+ await this.releaseLock();
51
+ } finally {
52
+ this.acquired = false;
53
+ }
54
+ }
55
+ }