@applica-software-guru/sdd-core 1.8.2 → 1.8.3

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 (51) hide show
  1. package/dist/agent/agent-defaults.js +2 -2
  2. package/dist/agent/agent-defaults.js.map +1 -1
  3. package/dist/agent/agent-runner.d.ts +17 -0
  4. package/dist/agent/agent-runner.d.ts.map +1 -1
  5. package/dist/agent/agent-runner.js +163 -11
  6. package/dist/agent/agent-runner.js.map +1 -1
  7. package/dist/agent/worker-daemon.d.ts +12 -0
  8. package/dist/agent/worker-daemon.d.ts.map +1 -0
  9. package/dist/agent/worker-daemon.js +220 -0
  10. package/dist/agent/worker-daemon.js.map +1 -0
  11. package/dist/git/git.d.ts +6 -0
  12. package/dist/git/git.d.ts.map +1 -1
  13. package/dist/git/git.js +60 -0
  14. package/dist/git/git.js.map +1 -1
  15. package/dist/index.d.ts +7 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +17 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/prompt/draft-prompt-generator.d.ts.map +1 -1
  20. package/dist/prompt/draft-prompt-generator.js +4 -0
  21. package/dist/prompt/draft-prompt-generator.js.map +1 -1
  22. package/dist/prompt/prompt-generator.d.ts.map +1 -1
  23. package/dist/prompt/prompt-generator.js +5 -0
  24. package/dist/prompt/prompt-generator.js.map +1 -1
  25. package/dist/remote/sync-engine.d.ts.map +1 -1
  26. package/dist/remote/sync-engine.js +1 -0
  27. package/dist/remote/sync-engine.js.map +1 -1
  28. package/dist/remote/worker-client.d.ts +22 -0
  29. package/dist/remote/worker-client.d.ts.map +1 -0
  30. package/dist/remote/worker-client.js +88 -0
  31. package/dist/remote/worker-client.js.map +1 -0
  32. package/dist/remote/worker-types.d.ts +38 -0
  33. package/dist/remote/worker-types.d.ts.map +1 -0
  34. package/dist/remote/worker-types.js +3 -0
  35. package/dist/remote/worker-types.js.map +1 -0
  36. package/dist/scaffold/templates.generated.d.ts +1 -1
  37. package/dist/scaffold/templates.generated.d.ts.map +1 -1
  38. package/dist/scaffold/templates.generated.js +51 -33
  39. package/dist/scaffold/templates.generated.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/agent/agent-defaults.ts +2 -2
  42. package/src/agent/agent-runner.ts +184 -12
  43. package/src/agent/worker-daemon.ts +254 -0
  44. package/src/git/git.ts +61 -0
  45. package/src/index.ts +7 -2
  46. package/src/prompt/draft-prompt-generator.ts +8 -0
  47. package/src/prompt/prompt-generator.ts +8 -0
  48. package/src/remote/sync-engine.ts +1 -0
  49. package/src/remote/worker-client.ts +141 -0
  50. package/src/remote/worker-types.ts +40 -0
  51. package/src/scaffold/templates.generated.ts +51 -33
@@ -228,13 +228,14 @@ name: sdd-remote
228
228
  description: >
229
229
  Remote sync workflow for Story Driven Development. Use when the user asks
230
230
  to update local state from remote changes, process remote drafts, and push
231
- enriched items back.
231
+ enriched items back. Also applies when running a remote worker job (enrich
232
+ or sync).
232
233
  license: MIT
233
234
  compatibility: Requires sdd CLI (npm i -g @applica-software-guru/sdd)
234
235
  allowed-tools: Bash(sdd:*) Read Glob Grep
235
236
  metadata:
236
237
  author: applica-software-guru
237
- version: "1.0"
238
+ version: "1.1"
238
239
  ---
239
240
 
240
241
  # SDD Remote - Pull, Enrich, Push
@@ -244,6 +245,9 @@ metadata:
244
245
  Use this skill to synchronize local SDD docs with remote updates, enrich draft content,
245
246
  and publish the enriched result to remote in active states.
246
247
 
248
+ This skill also applies when a **remote worker job** is dispatched from SDD Flow, as the
249
+ worker runs these same workflows on behalf of the user.
250
+
247
251
  ## Detection
248
252
 
249
253
  This workflow applies when:
@@ -251,79 +255,91 @@ This workflow applies when:
251
255
  - \`.sdd/config.yaml\` exists in the project root
252
256
  - The user asks to update local state from remote, pull pending CRs/bugs/docs,
253
257
  enrich drafts, or push pending remote updates
258
+ - A remote worker job prompt instructs you to follow this workflow
254
259
 
255
- ## Workflow
260
+ ## Workflows
261
+
262
+ ### Enrich Workflow (CR)
256
263
 
257
- Follow this sequence in order:
264
+ Follow this sequence to enrich a draft Change Request:
258
265
 
259
- 1. Verify remote configuration
266
+ 1. Pull remote updates:
260
267
 
261
268
  \`\`\`bash
262
- test -f .sdd/config.yaml && sdd remote status
269
+ sdd pull --crs-only
263
270
  \`\`\`
264
271
 
265
- If remote is not configured or disconnected, run:
272
+ 3. Generate draft TODO list:
266
273
 
267
274
  \`\`\`bash
268
- sdd remote init
275
+ sdd drafts
269
276
  \`\`\`
270
277
 
271
- Then stop and ask the user for URL/API key if needed.
278
+ 4. Enrich the draft with technical details, acceptance criteria, edge cases, and
279
+ any relevant information from the project documentation and comments.
272
280
 
273
- 2. Pull remote updates (docs + CRs + bugs)
281
+ 5. Transition the enriched CR to pending:
274
282
 
275
283
  \`\`\`bash
276
- sdd pull
284
+ sdd mark-drafts-enriched
277
285
  \`\`\`
278
286
 
279
- Optional scoped pulls:
287
+ This performs: \`draft → pending\`
288
+
289
+ 6. Push the enriched content:
280
290
 
281
291
  \`\`\`bash
282
- sdd pull --docs-only
283
- sdd pull --crs-only
284
- sdd pull --bugs-only
292
+ sdd push
285
293
  \`\`\`
286
294
 
287
- 3. Generate draft TODO list for your coding agent
295
+ ### Enrich Workflow (Document)
296
+
297
+ Follow this sequence to enrich a document:
298
+
299
+ 1. Pull remote updates:
288
300
 
289
301
  \`\`\`bash
290
- sdd drafts
302
+ sdd pull --docs-only
291
303
  \`\`\`
292
304
 
293
- This command lists all local draft docs, CRs, and bugs and prints a minimal TODO-style prompt.
294
- Give that prompt to your coding agent. If additional context is needed, the agent can fetch it directly from project files.
305
+ 3. Locate the document file in \`product/\` or \`system/\` and update its content
306
+ with the enriched version.
295
307
 
296
- 4. Transition enriched drafts to active states
308
+ 4. Push the enriched content:
297
309
 
298
310
  \`\`\`bash
299
- sdd mark-drafts-enriched
311
+ sdd push
300
312
  \`\`\`
301
313
 
302
- This performs:
314
+ If the document was in \`draft\` status, it will transition to \`new\` on the server.
315
+
316
+ ### Sync Workflow (Project-level)
303
317
 
304
- - Document: \`draft -> new\`
305
- - Change Request: \`draft -> pending\`
306
- - Bug: \`draft -> open\`
318
+ Follow this sequence for a full project sync (all pending items):
307
319
 
308
- 5. Push local pending updates to remote
320
+ 1. Pull the latest specs:
309
321
 
310
322
  \`\`\`bash
311
- sdd push
323
+ sdd pull
312
324
  \`\`\`
313
325
 
314
- 6. Verify final remote sync state
326
+ 3. Run the \`sdd\` skill — it handles the full loop: open bugs, pending CRs,
327
+ documentation sync, code implementation, mark-synced, and commit.
328
+
329
+ 4. Push:
315
330
 
316
331
  \`\`\`bash
317
- sdd remote status
332
+ sdd push
318
333
  \`\`\`
319
334
 
320
335
  ## Rules
321
336
 
322
337
  1. Always check remote configuration before pull/push (\`sdd remote status\`)
323
- 2. Do not use \`sdd push --all\` unless the user explicitly asks for a full reseed
324
- 3. If pull reports conflicts, do not overwrite local files blindly; report conflicts and ask how to proceed
325
- 4. Do not edit files inside \`.sdd/\` manually
326
- 5. Keep status transitions explicit: enrich first, then \`sdd mark-drafts-enriched\`, then push
338
+ 3. Do not use \`sdd push --all\` unless the user explicitly asks for a full reseed
339
+ 4. If pull reports conflicts, do not overwrite local files blindly; report conflicts and ask how to proceed
340
+ 5. Do not edit files inside \`.sdd/\` manually
341
+ 6. Keep status transitions explicit: enrich first, then \`sdd mark-drafts-enriched\`, then push
342
+ 7. **Always commit before pushing** when the sync workflow makes code changes
327
343
 
328
344
  ## Related commands
329
345
 
@@ -332,6 +348,8 @@ sdd remote status
332
348
  - \`sdd pull\`
333
349
  - \`sdd drafts\`
334
350
  - \`sdd mark-drafts-enriched\`
351
+ - \`sdd sync\`
352
+ - \`sdd mark-synced\`
335
353
  - \`sdd push\`
336
354
  `;
337
355
  exports.FILE_FORMAT_REFERENCE = `# File Format and Status Lifecycle
@@ -1 +1 @@
1
- {"version":3,"file":"templates.generated.js","sourceRoot":"","sources":["../../src/scaffold/templates.generated.ts"],"names":[],"mappings":";AAAA,6DAA6D;AAC7D,qEAAqE;;;AAExD,QAAA,iBAAiB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyGhC,CAAC;AAEW,QAAA,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiHnC,CAAC;AAEW,QAAA,wBAAwB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8GvC,CAAC;AAEW,QAAA,qBAAqB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiEpC,CAAC;AAEW,QAAA,yBAAyB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BxC,CAAC;AAEW,QAAA,cAAc,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6B7B,CAAC"}
1
+ {"version":3,"file":"templates.generated.js","sourceRoot":"","sources":["../../src/scaffold/templates.generated.ts"],"names":[],"mappings":";AAAA,6DAA6D;AAC7D,qEAAqE;;;AAExD,QAAA,iBAAiB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyGhC,CAAC;AAEW,QAAA,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiHnC,CAAC;AAEW,QAAA,wBAAwB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgIvC,CAAC;AAEW,QAAA,qBAAqB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiEpC,CAAC;AAEW,QAAA,yBAAyB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BxC,CAAC;AAEW,QAAA,cAAc,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6B7B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applica-software-guru/sdd-core",
3
- "version": "1.8.2",
3
+ "version": "1.8.3",
4
4
  "description": "Core library for Story Driven Development",
5
5
  "author": "Bruno Fortunato <bruno.fortunato@applica.guru>",
6
6
  "main": "dist/index.js",
@@ -1,6 +1,6 @@
1
1
  export const DEFAULT_AGENTS: Record<string, string> = {
2
- claude: 'claude -p "$(cat $PROMPT_FILE)" --dangerously-skip-permissions --verbose',
3
- codex: 'codex -q "$(cat $PROMPT_FILE)"',
2
+ claude: 'claude -p "$(cat $PROMPT_FILE)" --permission-mode auto --verbose --model $MODEL',
3
+ codex: 'codex -q "$(cat $PROMPT_FILE)" -m $MODEL',
4
4
  opencode: 'opencode -p "$(cat $PROMPT_FILE)"',
5
5
  };
6
6
 
@@ -1,4 +1,4 @@
1
- import { spawn } from 'node:child_process';
1
+ import { spawn, type ChildProcess } from 'node:child_process';
2
2
  import { writeFile, unlink } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
@@ -9,38 +9,164 @@ export interface AgentRunnerOptions {
9
9
  root: string;
10
10
  prompt: string;
11
11
  agent: string;
12
+ model?: string;
12
13
  agents?: Record<string, string>;
13
14
  onOutput?: (data: string) => void;
14
15
  }
15
16
 
16
- export async function runAgent(options: AgentRunnerOptions): Promise<number> {
17
- const { root, prompt, agent, agents, onOutput } = options;
17
+ /** Handle returned by startAgent() for interactive worker mode. */
18
+ export interface AgentRunnerHandle {
19
+ /** Resolves with the exit code when the agent process exits. */
20
+ exitPromise: Promise<number>;
21
+ /** Write data to the agent's stdin (for Q&A relay). */
22
+ writeStdin: (data: string) => void;
23
+ /** Kill the agent process. */
24
+ kill: () => void;
25
+ /** Path to the temp prompt file (cleaned up on exit). */
26
+ promptFile: string;
27
+ }
28
+
29
+ /**
30
+ * Extract human-readable text from a single stream-json NDJSON line.
31
+ * Returns the text to emit, or null if the line should be suppressed.
32
+ * Falls back to the raw line if it is not valid JSON (e.g. stderr noise).
33
+ */
34
+ function extractStreamJsonText(line: string): string | null {
35
+ let event: unknown;
36
+ try {
37
+ event = JSON.parse(line);
38
+ } catch {
39
+ // Not JSON — pass raw (e.g. stderr lines from subprocesses)
40
+ return line;
41
+ }
42
+
43
+ if (typeof event !== 'object' || event === null || !('type' in event)) {
44
+ return null;
45
+ }
46
+
47
+ const { type } = event as Record<string, unknown>;
48
+
49
+ if (type === 'assistant') {
50
+ const message = (event as Record<string, unknown>).message;
51
+ if (typeof message === 'object' && message !== null && 'content' in message) {
52
+ const content = (message as Record<string, unknown>).content;
53
+ if (Array.isArray(content)) {
54
+ const texts = content
55
+ .filter((b): b is { type: string; text: string } => b?.type === 'text' && typeof b?.text === 'string')
56
+ .map((b) => b.text);
57
+ return texts.length > 0 ? texts.join('') : null;
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+
63
+ if (type === 'result') {
64
+ const result = (event as Record<string, unknown>).result;
65
+ if (typeof result === 'string' && result.trim()) {
66
+ return result;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ // Suppress system/init/tool events — not useful for display
72
+ return null;
73
+ }
74
+
75
+ /**
76
+ * Attach NDJSON-parsing stream handlers to a child process.
77
+ * Each complete JSON line is parsed and the extracted text forwarded to `sink`.
78
+ * stderr lines are forwarded raw (they are not stream-json).
79
+ */
80
+ function pipeWithNdjsonParsing(
81
+ child: ChildProcess,
82
+ sink: (text: string) => void,
83
+ ): void {
84
+ if (child.stdout) {
85
+ let buf = '';
86
+ child.stdout.on('data', (data: Buffer) => {
87
+ buf += data.toString();
88
+ const lines = buf.split('\n');
89
+ buf = lines.pop() ?? '';
90
+ for (const line of lines) {
91
+ if (!line.trim()) continue;
92
+ const text = extractStreamJsonText(line);
93
+ if (text) sink(text);
94
+ }
95
+ });
96
+ child.stdout.on('end', () => {
97
+ if (buf.trim()) {
98
+ const text = extractStreamJsonText(buf);
99
+ if (text) sink(text);
100
+ }
101
+ });
102
+ }
103
+
104
+ if (child.stderr) {
105
+ child.stderr.on('data', (data: Buffer) => sink(data.toString()));
106
+ }
107
+ }
108
+
109
+ async function prepareAgent(options: AgentRunnerOptions): Promise<{ command: string; tmpFile: string; isClaudeAgent: boolean }> {
110
+ const { prompt, agent, model, agents } = options;
18
111
 
19
112
  const template = resolveAgentCommand(agent, agents);
20
113
  if (!template) {
21
114
  throw new Error(`Unknown agent "${agent}". Available: ${Object.keys(agents ?? {}).join(', ') || 'claude, codex, opencode'}`);
22
115
  }
23
116
 
24
- // Write prompt to temp file (too large for CLI arg)
25
117
  const tmpFile = join(tmpdir(), `sdd-prompt-${randomBytes(6).toString('hex')}.md`);
26
118
  await writeFile(tmpFile, prompt, 'utf-8');
27
119
 
28
- // Replace $PROMPT_FILE with the temp file path in the command template
29
- const command = template.replace(/\$PROMPT_FILE/g, tmpFile);
120
+ let command = template.replace(/\$PROMPT_FILE/g, tmpFile);
121
+ if (model) {
122
+ command = command.replace(/\$MODEL/g, model);
123
+ // If template doesn't have $MODEL but model is specified, inject --model flag
124
+ if (!template.includes('$MODEL')) {
125
+ command = command.replace(/^(\S+)/, `$1 --model ${model}`);
126
+ }
127
+ } else {
128
+ // Remove $MODEL placeholder and surrounding flags if not provided
129
+ command = command.replace(/--model\s+\$MODEL\s*/g, '');
130
+ command = command.replace(/\$MODEL\s*/g, '');
131
+ }
132
+
133
+ // For the claude CLI: switch to stream-json output for real-time streaming.
134
+ // --verbose is required alongside --output-format stream-json when using -p.
135
+ const isClaudeAgent = command.trimStart().startsWith('claude');
136
+ if (isClaudeAgent) {
137
+ // Ensure --verbose is present (required by stream-json in print mode)
138
+ if (!command.includes('--verbose')) {
139
+ command = command.trimEnd() + ' --verbose';
140
+ }
141
+ command = command.trimEnd() + ' --output-format stream-json';
142
+ }
143
+
144
+ return { command, tmpFile, isClaudeAgent };
145
+ }
146
+
147
+ export async function runAgent(options: AgentRunnerOptions): Promise<number> {
148
+ const { root, onOutput } = options;
149
+ const { command, tmpFile, isClaudeAgent } = await prepareAgent(options);
30
150
 
31
151
  try {
32
152
  const exitCode = await new Promise<number>((resolve, reject) => {
153
+ // For claude with stream-json we always pipe stdout so we can parse NDJSON.
154
+ // For other agents we inherit stdio when no onOutput is provided.
155
+ const usesPipe = isClaudeAgent || !!onOutput;
33
156
  const child = spawn(command, {
34
157
  cwd: root,
35
158
  shell: true,
36
- stdio: onOutput ? ['inherit', 'pipe', 'pipe'] : 'inherit',
159
+ stdio: usesPipe ? ['inherit', 'pipe', 'pipe'] : 'inherit',
37
160
  });
38
161
 
39
- if (onOutput && child.stdout) {
40
- child.stdout.on('data', (data: Buffer) => onOutput(data.toString()));
41
- }
42
- if (onOutput && child.stderr) {
43
- child.stderr.on('data', (data: Buffer) => onOutput(data.toString()));
162
+ if (usesPipe) {
163
+ const sink = onOutput ?? ((text: string) => process.stdout.write(text));
164
+ if (isClaudeAgent) {
165
+ pipeWithNdjsonParsing(child, sink);
166
+ } else {
167
+ if (child.stdout) child.stdout.on('data', (data: Buffer) => sink(data.toString()));
168
+ if (child.stderr) child.stderr.on('data', (data: Buffer) => sink(data.toString()));
169
+ }
44
170
  }
45
171
 
46
172
  child.on('error', reject);
@@ -52,3 +178,49 @@ export async function runAgent(options: AgentRunnerOptions): Promise<number> {
52
178
  await unlink(tmpFile).catch(() => {});
53
179
  }
54
180
  }
181
+
182
+ /**
183
+ * Start an agent in interactive mode (stdin piped for Q&A).
184
+ * Returns a handle for writing to stdin, killing the process, and awaiting exit.
185
+ */
186
+ export async function startAgent(options: AgentRunnerOptions): Promise<AgentRunnerHandle> {
187
+ const { root, onOutput } = options;
188
+ const { command, tmpFile, isClaudeAgent } = await prepareAgent(options);
189
+
190
+ const child: ChildProcess = spawn(command, {
191
+ cwd: root,
192
+ shell: true,
193
+ stdio: ['pipe', 'pipe', 'pipe'],
194
+ });
195
+
196
+ const sink = onOutput ?? ((text: string) => process.stdout.write(text));
197
+ if (isClaudeAgent) {
198
+ pipeWithNdjsonParsing(child, sink);
199
+ } else {
200
+ if (child.stdout) child.stdout.on('data', (data: Buffer) => sink(data.toString()));
201
+ if (child.stderr) child.stderr.on('data', (data: Buffer) => sink(data.toString()));
202
+ }
203
+
204
+ const exitPromise = new Promise<number>((resolve, reject) => {
205
+ child.on('error', reject);
206
+ child.on('close', (code) => {
207
+ unlink(tmpFile).catch(() => {});
208
+ resolve(code ?? 1);
209
+ });
210
+ });
211
+
212
+ return {
213
+ exitPromise,
214
+ writeStdin: (data: string) => {
215
+ if (child.stdin && !child.stdin.destroyed) {
216
+ child.stdin.write(data);
217
+ }
218
+ },
219
+ kill: () => {
220
+ if (!child.killed) {
221
+ child.kill('SIGTERM');
222
+ }
223
+ },
224
+ promptFile: tmpFile,
225
+ };
226
+ }
@@ -0,0 +1,254 @@
1
+ import { hostname } from 'node:os';
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import type { ApiClientConfig } from '../remote/api-client.js';
5
+ import type { WorkerState, WorkerJobAssignment } from '../remote/worker-types.js';
6
+ import {
7
+ registerWorker,
8
+ workerHeartbeat,
9
+ workerPoll,
10
+ workerJobStarted,
11
+ workerJobOutput,
12
+ workerJobQuestion,
13
+ workerJobAnswers,
14
+ workerJobCompleted,
15
+ } from '../remote/worker-client.js';
16
+ import { startAgent } from './agent-runner.js';
17
+ import { checkoutBranch, getCurrentBranch, getCurrentCommit, getJobChangedFiles } from '../git/git.js';
18
+
19
+ export interface WorkerDaemonOptions {
20
+ root: string;
21
+ name?: string;
22
+ agent: string;
23
+ agents?: Record<string, string>;
24
+ apiConfig: ApiClientConfig;
25
+ onLog?: (message: string) => void;
26
+ renderPrompt?: (prompt: string) => string;
27
+ }
28
+
29
+ const HEARTBEAT_INTERVAL_MS = 15_000;
30
+ const OUTPUT_FLUSH_INTERVAL_MS = 2_000;
31
+ const QA_POLL_INTERVAL_MS = 2_000;
32
+ const WORKER_STATE_FILE = 'worker.json';
33
+
34
+ export async function startWorkerDaemon(options: WorkerDaemonOptions): Promise<void> {
35
+ const { root, agent, agents, apiConfig, onLog, renderPrompt } = options;
36
+ const workerName = options.name ?? hostname();
37
+ const sddDir = join(root, '.sdd');
38
+ const stateFile = join(sddDir, WORKER_STATE_FILE);
39
+
40
+ const log = onLog ?? ((msg: string) => process.stderr.write(`[worker] ${msg}\n`));
41
+ const shortId = (id: string) => id.slice(0, 8);
42
+
43
+ // --- Read current branch (informational — no checkout) ---
44
+ const branch = getCurrentBranch(root) ?? undefined;
45
+
46
+ // --- Register ---
47
+ log(`Registering worker "${workerName}" with agent "${agent}"${branch ? ` (branch: ${branch})` : ''}...`);
48
+ const registration = await registerWorker(apiConfig, workerName, agent, branch, {
49
+ hostname: hostname(),
50
+ platform: process.platform,
51
+ arch: process.arch,
52
+ });
53
+
54
+ const state: WorkerState = {
55
+ workerId: registration.id,
56
+ name: workerName,
57
+ registeredAt: registration.registered_at,
58
+ };
59
+ await writeFile(stateFile, JSON.stringify(state, null, 2), 'utf-8');
60
+ log(`Registered as worker ${shortId(registration.id)}`);
61
+
62
+ let running = true;
63
+ let currentJobKill: (() => void) | null = null;
64
+
65
+ // --- Graceful shutdown ---
66
+ const shutdown = async () => {
67
+ log('Shutting down...');
68
+ running = false;
69
+ if (currentJobKill) {
70
+ currentJobKill();
71
+ }
72
+ try {
73
+ await workerHeartbeat(apiConfig, registration.id, 'online');
74
+ } catch {
75
+ // best effort
76
+ }
77
+ };
78
+
79
+ process.on('SIGINT', () => { shutdown(); });
80
+ process.on('SIGTERM', () => { shutdown(); });
81
+
82
+ // --- Heartbeat loop ---
83
+ const heartbeatLoop = async () => {
84
+ while (running) {
85
+ try {
86
+ await workerHeartbeat(apiConfig, registration.id, currentJobKill ? 'busy' : 'online');
87
+ } catch (err) {
88
+ const msg = (err as Error).message;
89
+ if (!msg.includes('abort')) {
90
+ log(`Heartbeat error: ${msg}`);
91
+ }
92
+ }
93
+ await sleep(HEARTBEAT_INTERVAL_MS);
94
+ }
95
+ };
96
+
97
+ // --- Poll & execute loop ---
98
+ const pollLoop = async () => {
99
+ while (running) {
100
+ try {
101
+ const job = await workerPoll(apiConfig, registration.id);
102
+ if (job && running) {
103
+ await executeJob(job);
104
+ }
105
+ } catch (err) {
106
+ if (!running) break;
107
+ const msg = (err as Error).message;
108
+ // Don't log abort errors during shutdown
109
+ if (!msg.includes('abort')) {
110
+ log(`Poll error: ${msg}`);
111
+ }
112
+ await sleep(3_000);
113
+ }
114
+ }
115
+ };
116
+
117
+ // --- Job execution ---
118
+ const executeJob = async (job: WorkerJobAssignment) => {
119
+ const jobDesc = job.entity_type ? `${job.entity_type}/${job.entity_id}` : 'sync';
120
+ const jid = shortId(job.job_id);
121
+ log(`┌─ Job ${jid} (${jobDesc})`);
122
+ log(`│ Agent: ${job.agent}${job.model ? ` Model: ${job.model}` : ''}${job.branch ? ` Branch: ${job.branch}` : ''}`);
123
+ const promptPreview = job.prompt.split('\n').find(l => l.trim().length > 0) ?? '';
124
+ log(`└─ ${promptPreview.length > 80 ? promptPreview.slice(0, 80) + '…' : promptPreview}`);
125
+
126
+ // Checkout branch if the job explicitly requests one different from the current
127
+ if (job.branch && job.branch !== getCurrentBranch(root)) {
128
+ log(`Checking out branch "${job.branch}" for job ${job.job_id}...`);
129
+ try {
130
+ checkoutBranch(root, job.branch);
131
+ } catch (err) {
132
+ log(`Warning: could not checkout branch "${job.branch}": ${(err as Error).message}`);
133
+ }
134
+ }
135
+
136
+ // Capture git state before the job starts so we can diff at the end
137
+ const baseCommit = getCurrentCommit(root);
138
+
139
+ try {
140
+ await workerJobStarted(apiConfig, job.job_id);
141
+ } catch (err) {
142
+ log(`Failed to notify job start: ${(err as Error).message}`);
143
+ }
144
+
145
+ // Buffer for batched output
146
+ let outputBuffer: string[] = [];
147
+ let lastFlush = Date.now();
148
+ let lastAnswerSequence = 0;
149
+ let waitingForAnswer = false;
150
+
151
+ const flushOutput = async () => {
152
+ if (outputBuffer.length === 0) return;
153
+ const lines = outputBuffer.splice(0);
154
+ try {
155
+ await workerJobOutput(apiConfig, job.job_id, lines);
156
+ } catch (err) {
157
+ log(`Output flush error: ${(err as Error).message}`);
158
+ }
159
+ };
160
+
161
+ // Start the agent with interactive mode
162
+ const handle = await startAgent({
163
+ root,
164
+ prompt: job.prompt,
165
+ agent: job.agent,
166
+ model: job.model,
167
+ agents,
168
+ onOutput: (data: string) => {
169
+ // Split into lines but preserve partial lines
170
+ const lines = data.split('\n');
171
+ for (const line of lines) {
172
+ // Suppress the Claude CLI stdin warning — prompt is passed via -p flag,
173
+ // stdin is kept open only for Q&A relay and does not need initial data.
174
+ if (line.includes('no stdin data received') || line.includes('redirect stdin explicitly')) {
175
+ continue;
176
+ }
177
+ if (line.length > 0) {
178
+ outputBuffer.push(line);
179
+ }
180
+ }
181
+
182
+ // Flush if enough time has passed
183
+ if (Date.now() - lastFlush >= OUTPUT_FLUSH_INTERVAL_MS) {
184
+ lastFlush = Date.now();
185
+ flushOutput();
186
+ }
187
+ },
188
+ });
189
+
190
+ currentJobKill = handle.kill;
191
+
192
+ // Periodic output flush + answer polling loop
193
+ const flushAndPollLoop = async () => {
194
+ while (running) {
195
+ // Flush pending output
196
+ await flushOutput();
197
+ lastFlush = Date.now();
198
+
199
+ // If we detected a question, poll for answers
200
+ if (waitingForAnswer) {
201
+ try {
202
+ const answers = await workerJobAnswers(apiConfig, job.job_id, lastAnswerSequence);
203
+ if (answers.length > 0) {
204
+ for (const ans of answers) {
205
+ handle.writeStdin(ans.content + '\n');
206
+ lastAnswerSequence = ans.sequence;
207
+ }
208
+ waitingForAnswer = false;
209
+ }
210
+ } catch (err) {
211
+ log(`Answer poll error: ${(err as Error).message}`);
212
+ }
213
+ }
214
+
215
+ await sleep(QA_POLL_INTERVAL_MS);
216
+ }
217
+ };
218
+
219
+ // Run the flush/poll loop concurrently with the agent
220
+ const flushPromise = flushAndPollLoop();
221
+
222
+ try {
223
+ const exitCode = await handle.exitPromise;
224
+
225
+ // Final flush
226
+ running = true; // temporarily re-enable to allow final flush
227
+ await flushOutput();
228
+
229
+ const changedFiles = getJobChangedFiles(root, baseCommit);
230
+ const filesMsg = changedFiles.length > 0 ? `, ${changedFiles.length} file(s) changed` : '';
231
+ log(`Job ${jid} done — exit ${exitCode}${filesMsg}`);
232
+ await workerJobCompleted(apiConfig, job.job_id, exitCode, changedFiles);
233
+ } catch (err) {
234
+ log(`Job ${jid} error: ${(err as Error).message}`);
235
+ try {
236
+ const changedFiles = getJobChangedFiles(root, baseCommit);
237
+ await workerJobCompleted(apiConfig, job.job_id, 1, changedFiles);
238
+ } catch {
239
+ // best effort
240
+ }
241
+ } finally {
242
+ currentJobKill = null;
243
+ }
244
+ };
245
+
246
+ // --- Start concurrent loops ---
247
+ log(`Worker "${workerName}" is online. Waiting for jobs… (id: ${shortId(registration.id)})`);
248
+ await Promise.all([heartbeatLoop(), pollLoop()]);
249
+ log('Worker stopped.');
250
+ }
251
+
252
+ function sleep(ms: number): Promise<void> {
253
+ return new Promise((resolve) => setTimeout(resolve, ms));
254
+ }