dev-loops 0.1.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 (156) hide show
  1. package/.pi/dev-loop/defaults.yaml +477 -0
  2. package/AGENTS.md +25 -0
  3. package/CHANGELOG.md +18 -0
  4. package/LICENSE +21 -0
  5. package/README.md +178 -0
  6. package/agents/dev-loop.agent.md +82 -0
  7. package/agents/developer.agent.md +37 -0
  8. package/agents/docs.agent.md +33 -0
  9. package/agents/fixer.agent.md +53 -0
  10. package/agents/quality.agent.md +28 -0
  11. package/agents/refiner.agent.md +87 -0
  12. package/agents/review.agent.md +64 -0
  13. package/cli/index.mjs +424 -0
  14. package/extension/README.md +233 -0
  15. package/extension/checks.ts +94 -0
  16. package/extension/index.ts +131 -0
  17. package/extension/post-merge-update.ts +512 -0
  18. package/extension/presentation.ts +107 -0
  19. package/lib/dev-loops-core.mjs +284 -0
  20. package/package.json +103 -0
  21. package/scripts/README.md +1007 -0
  22. package/scripts/_cli-primitives.mjs +10 -0
  23. package/scripts/_core-helpers.mjs +30 -0
  24. package/scripts/docs/validate-links.mjs +567 -0
  25. package/scripts/docs/validate-no-duplicate-rules.mjs +250 -0
  26. package/scripts/github/_review-thread-mutations.mjs +214 -0
  27. package/scripts/github/capture-review-threads.mjs +180 -0
  28. package/scripts/github/create-draft-pr.mjs +108 -0
  29. package/scripts/github/detect-checkpoint-evidence.mjs +393 -0
  30. package/scripts/github/detect-linked-issue-pr.mjs +331 -0
  31. package/scripts/github/manage-sub-issues.mjs +394 -0
  32. package/scripts/github/probe-copilot-review.mjs +323 -0
  33. package/scripts/github/ready-for-review.mjs +93 -0
  34. package/scripts/github/reconcile-draft-gate.mjs +328 -0
  35. package/scripts/github/reply-resolve-review-thread.mjs +42 -0
  36. package/scripts/github/reply-resolve-review-threads.mjs +329 -0
  37. package/scripts/github/request-copilot-review.mjs +551 -0
  38. package/scripts/github/resolve-tracker-local-spec.mjs +205 -0
  39. package/scripts/github/stage-reviewer-draft.mjs +191 -0
  40. package/scripts/github/upsert-checkpoint-verdict.mjs +694 -0
  41. package/scripts/github/verify-fresh-review-context.mjs +125 -0
  42. package/scripts/github/write-gate-findings-log.mjs +212 -0
  43. package/scripts/loop/_checkpoint-io.mjs +55 -0
  44. package/scripts/loop/_checkpoint-paths.mjs +28 -0
  45. package/scripts/loop/_handoff-contract.mjs +230 -0
  46. package/scripts/loop/_inspect-run-viewer-adapter.mjs +345 -0
  47. package/scripts/loop/_loop-evidence.mjs +32 -0
  48. package/scripts/loop/_pr-runner-coordination.mjs +611 -0
  49. package/scripts/loop/_stale-runner-detection.mjs +145 -0
  50. package/scripts/loop/_steering-state-file.mjs +134 -0
  51. package/scripts/loop/build-handoff-envelope.mjs +181 -0
  52. package/scripts/loop/checkpoint-contract.mjs +49 -0
  53. package/scripts/loop/conductor-monitor.mjs +1850 -0
  54. package/scripts/loop/conductor.mjs +214 -0
  55. package/scripts/loop/copilot-pr-handoff.mjs +493 -0
  56. package/scripts/loop/debt-remediate.mjs +304 -0
  57. package/scripts/loop/detect-change-scope.mjs +102 -0
  58. package/scripts/loop/detect-copilot-loop-state.mjs +454 -0
  59. package/scripts/loop/detect-copilot-session-activity.mjs +186 -0
  60. package/scripts/loop/detect-initial-copilot-pr-state.mjs +318 -0
  61. package/scripts/loop/detect-internal-only-pr.mjs +270 -0
  62. package/scripts/loop/detect-issue-refinement-artifact.mjs +163 -0
  63. package/scripts/loop/detect-pr-gate-coordination-state.mjs +509 -0
  64. package/scripts/loop/detect-reviewer-loop-state.mjs +231 -0
  65. package/scripts/loop/detect-stale-runner.mjs +250 -0
  66. package/scripts/loop/detect-tracker-first-loop-state.mjs +76 -0
  67. package/scripts/loop/detect-tracker-pr-state.mjs +102 -0
  68. package/scripts/loop/info.mjs +267 -0
  69. package/scripts/loop/inspect-run-viewer/cli.mjs +117 -0
  70. package/scripts/loop/inspect-run-viewer/constants.mjs +80 -0
  71. package/scripts/loop/inspect-run-viewer/graph.mjs +757 -0
  72. package/scripts/loop/inspect-run-viewer/handoff-envelope-renderer.mjs +398 -0
  73. package/scripts/loop/inspect-run-viewer/inbox.mjs +308 -0
  74. package/scripts/loop/inspect-run-viewer/managed-instance.mjs +750 -0
  75. package/scripts/loop/inspect-run-viewer/rendering.mjs +411 -0
  76. package/scripts/loop/inspect-run-viewer/server.mjs +638 -0
  77. package/scripts/loop/inspect-run-viewer/shared.mjs +103 -0
  78. package/scripts/loop/inspect-run-viewer/status.mjs +715 -0
  79. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +77 -0
  80. package/scripts/loop/inspect-run-viewer.mjs +82 -0
  81. package/scripts/loop/inspect-run.mjs +382 -0
  82. package/scripts/loop/outer-loop.mjs +419 -0
  83. package/scripts/loop/pr-runner-coordination.mjs +143 -0
  84. package/scripts/loop/pre-commit-branch-guard.mjs +68 -0
  85. package/scripts/loop/pre-flight-gate.mjs +236 -0
  86. package/scripts/loop/pre-pr-ready-gate.mjs +183 -0
  87. package/scripts/loop/pre-push-main-guard.mjs +103 -0
  88. package/scripts/loop/pre-write-remote-freshness-guard.mjs +32 -0
  89. package/scripts/loop/print-gates.mjs +42 -0
  90. package/scripts/loop/resolve-dev-loop-startup.mjs +533 -0
  91. package/scripts/loop/run-conductor-cycle.mjs +322 -0
  92. package/scripts/loop/run-queue.mjs +124 -0
  93. package/scripts/loop/run-refinement-audit.mjs +513 -0
  94. package/scripts/loop/run-watch-cycle.mjs +358 -0
  95. package/scripts/loop/steer-loop.mjs +841 -0
  96. package/scripts/loop/ui-designer-review-contract.mjs +76 -0
  97. package/scripts/loop/watch-initial-copilot-pr.mjs +253 -0
  98. package/scripts/projects/add-queue-item.mjs +528 -0
  99. package/scripts/projects/ensure-queue-board.mjs +837 -0
  100. package/scripts/projects/list-queue-items.mjs +489 -0
  101. package/scripts/projects/move-queue-item.mjs +549 -0
  102. package/scripts/projects/reorder-queue-item.mjs +518 -0
  103. package/scripts/refine/_refine-helpers.mjs +258 -0
  104. package/scripts/refine/prose-linkage-detector.mjs +92 -0
  105. package/scripts/refine/refinement-completeness-checker.mjs +88 -0
  106. package/scripts/refine/scope-boundary-cross-checker.mjs +163 -0
  107. package/scripts/refine/tree-integrity-validator.mjs +211 -0
  108. package/scripts/refine/verify.mjs +178 -0
  109. package/scripts/repo-wiki-local.mjs +156 -0
  110. package/scripts/repo-wiki.mjs +119 -0
  111. package/skills/copilot-pr-followup/SKILL.md +380 -0
  112. package/skills/dev-loop/SKILL.md +141 -0
  113. package/skills/dev-loop/scripts/dev-mode-context.mjs +152 -0
  114. package/skills/dev-loop/scripts/dev-mode-context.test.mjs +80 -0
  115. package/skills/dev-loop/scripts/init-phase.mjs +71 -0
  116. package/skills/dev-loop/scripts/log-bash-exit-1.mjs +25 -0
  117. package/skills/dev-loop/scripts/phase-files.mjs +29 -0
  118. package/skills/dev-loop/scripts/post-gate-verdict-fallback.mjs +480 -0
  119. package/skills/dev-loop/scripts/post-gate-verdict-fallback.test.mjs +732 -0
  120. package/skills/dev-loop/scripts/render-template.mjs +82 -0
  121. package/skills/dev-loop/scripts/render-template.test.mjs +63 -0
  122. package/skills/dev-loop/templates/bootstrap-agents.md +26 -0
  123. package/skills/dev-loop/templates/bootstrap-implementation-state.md +31 -0
  124. package/skills/dev-loop/templates/bootstrap-implementation-workflow.md +17 -0
  125. package/skills/dev-loop/templates/dev-mode-retrospective.md +15 -0
  126. package/skills/dev-loop/templates/dev-mode-review.md +17 -0
  127. package/skills/dev-loop/templates/dev-mode-skill-changes.md +11 -0
  128. package/skills/dev-loop/templates/merged-phase-plan.md +19 -0
  129. package/skills/dev-loop/templates/phase-doc.md +27 -0
  130. package/skills/dev-loop/templates/phase-summary.md +13 -0
  131. package/skills/dev-loop/templates/phase-variant.md +15 -0
  132. package/skills/dev-loop/templates/retrospective.md +11 -0
  133. package/skills/dev-loop/templates/review.md +32 -0
  134. package/skills/dev-loop/templates/ui-vision-review.md +55 -0
  135. package/skills/docs/acceptance-criteria-verification.md +21 -0
  136. package/skills/docs/anti-patterns.md +21 -0
  137. package/skills/docs/artifact-authority-contract.md +119 -0
  138. package/skills/docs/confirmation-rules.md +28 -0
  139. package/skills/docs/copilot-ci-status-contract.md +52 -0
  140. package/skills/docs/copilot-loop-operations.md +233 -0
  141. package/skills/docs/debt-remediation-contract.md +107 -0
  142. package/skills/docs/entrypoint-strategies.md +115 -0
  143. package/skills/docs/epic-tree-refinement-procedure.md +234 -0
  144. package/skills/docs/issue-intake-procedure.md +235 -0
  145. package/skills/docs/main-agent-contract.md +72 -0
  146. package/skills/docs/merge-preconditions.md +29 -0
  147. package/skills/docs/pr-lifecycle-contract.md +209 -0
  148. package/skills/docs/public-dev-loop-contract.md +497 -0
  149. package/skills/docs/retrospective-checkpoint-contract.md +159 -0
  150. package/skills/docs/stop-conditions.md +29 -0
  151. package/skills/docs/structural-quality.md +42 -0
  152. package/skills/docs/tracker-first-loop-state.md +281 -0
  153. package/skills/docs/validation-policy.md +27 -0
  154. package/skills/docs/workflow-handoff-contract.md +135 -0
  155. package/skills/final-approval/SKILL.md +19 -0
  156. package/skills/local-implementation/SKILL.md +640 -0
@@ -0,0 +1,750 @@
1
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ import { normalizeCliRepoOption } from './cli.mjs';
7
+ import { DEFAULT_HOST, DEFAULT_PORT } from './constants.mjs';
8
+ import { formatInspectRunViewerUrl, listListeningPidsForPort } from './server.mjs';
9
+
10
+ export const INSPECT_RUN_VIEWER_SURFACE_ID = 'inspect-run-viewer';
11
+ export const INSPECT_RUN_VIEWER_MANAGED_RECORD_PATH = '.pi/ui-servers/inspect-run-viewer.json';
12
+ const VIEWER_SCRIPT_PATH = fileURLToPath(new URL('../inspect-run-viewer.mjs', import.meta.url));
13
+
14
+ function sleep(ms) {
15
+ return new Promise((resolve) => setTimeout(resolve, ms));
16
+ }
17
+
18
+ function normalizeListenerDiscoveryError(error) {
19
+ const missingLsof = error?.code === 'ENOENT'
20
+ && (error?.path === 'lsof' || /(^|\b)lsof(\b|$)/i.test(String(error?.message ?? '')));
21
+ if (!missingLsof) {
22
+ return error;
23
+ }
24
+ return new Error('inspect-run viewer lifecycle requires lsof/POSIX support to inspect local listeners; install lsof or use the script fallback.');
25
+ }
26
+
27
+ function normalizeRequestedRepo(repo) {
28
+ if (repo === undefined || repo === null || `${repo}`.trim() === '') {
29
+ return null;
30
+ }
31
+ return normalizeCliRepoOption(`${repo}`);
32
+ }
33
+
34
+ function requireRepoRoot(repoRoot) {
35
+ if (typeof repoRoot !== 'string' || repoRoot.trim() === '') {
36
+ throw new Error('inspect-run viewer lifecycle requires a repoRoot.');
37
+ }
38
+ return repoRoot;
39
+ }
40
+
41
+ function buildLaunchArgs(repo) {
42
+ return {
43
+ repo,
44
+ host: DEFAULT_HOST,
45
+ port: DEFAULT_PORT,
46
+ };
47
+ }
48
+
49
+ function buildArgsFingerprint(launchArgs) {
50
+ return JSON.stringify({ repo: launchArgs.repo, host: launchArgs.host, port: launchArgs.port });
51
+ }
52
+
53
+ function buildRecordPayload({ repoRoot, launchArgs, pid, startedAt }) {
54
+ return {
55
+ schemaVersion: 1,
56
+ surfaceId: INSPECT_RUN_VIEWER_SURFACE_ID,
57
+ argsFingerprint: buildArgsFingerprint(launchArgs),
58
+ launchArgs,
59
+ host: launchArgs.host,
60
+ port: launchArgs.port,
61
+ url: formatInspectRunViewerUrl(launchArgs.host, launchArgs.port),
62
+ pid,
63
+ startedAt,
64
+ cwd: repoRoot,
65
+ };
66
+ }
67
+
68
+ function isManagedRecordShape(record) {
69
+ return Boolean(record)
70
+ && record.surfaceId === INSPECT_RUN_VIEWER_SURFACE_ID
71
+ && record.schemaVersion === 1
72
+ && Number.isInteger(record.pid)
73
+ && record.pid > 0
74
+ && record.host === DEFAULT_HOST
75
+ && record.port === DEFAULT_PORT
76
+ && record.launchArgs
77
+ && record.launchArgs.host === DEFAULT_HOST
78
+ && record.launchArgs.port === DEFAULT_PORT;
79
+ }
80
+
81
+ function baseUrlForRecord(record) {
82
+ const host = record?.host ?? DEFAULT_HOST;
83
+ const port = record?.port ?? DEFAULT_PORT;
84
+ return formatInspectRunViewerUrl(host, port);
85
+ }
86
+
87
+ function buildOperatorUrl(record, requestedRepo) {
88
+ const baseUrl = baseUrlForRecord(record);
89
+ const managedRepo = record?.launchArgs?.repo ?? null;
90
+ if (requestedRepo === null || managedRepo !== null) {
91
+ return baseUrl;
92
+ }
93
+ const url = new URL(baseUrl);
94
+ url.searchParams.set('scope', requestedRepo);
95
+ return url.toString().replace(/\/$/, '');
96
+ }
97
+
98
+ function canServeRequestedRepo(record, requestedRepo) {
99
+ if (!record) {
100
+ return false;
101
+ }
102
+ if (requestedRepo === null) {
103
+ return true;
104
+ }
105
+ const managedRepo = record.launchArgs?.repo ?? null;
106
+ return managedRepo === null || managedRepo === requestedRepo;
107
+ }
108
+
109
+ async function defaultIsProcessAlive(pid) {
110
+ try {
111
+ process.kill(pid, 0);
112
+ return true;
113
+ } catch (error) {
114
+ if (error?.code === 'ESRCH') {
115
+ return false;
116
+ }
117
+ throw error;
118
+ }
119
+ }
120
+
121
+ async function defaultHealthcheck(url) {
122
+ try {
123
+ const response = await fetch(url, { method: 'GET' });
124
+ return response.status === 200;
125
+ } catch {
126
+ return false;
127
+ }
128
+ }
129
+
130
+ async function defaultLaunchManagedServer({ repoRoot, repo, host, port }) {
131
+ const args = [VIEWER_SCRIPT_PATH, '--host', host, '--port', String(port)];
132
+ if (repo !== null) {
133
+ args.push('--repo', repo);
134
+ }
135
+ return await new Promise((resolve, reject) => {
136
+ const child = spawn(process.execPath, args, {
137
+ cwd: repoRoot,
138
+ detached: true,
139
+ stdio: 'ignore',
140
+ env: process.env,
141
+ });
142
+ child.once('error', reject);
143
+ child.once('spawn', () => {
144
+ child.unref();
145
+ resolve({ pid: child.pid });
146
+ });
147
+ });
148
+ }
149
+
150
+ async function defaultStopManagedProcess(pid) {
151
+ process.kill(pid, 'SIGTERM');
152
+ }
153
+
154
+ export function buildOpenBrowserInvocation(url, platform = process.platform) {
155
+ let command;
156
+ let args;
157
+ let options = { detached: true, stdio: 'ignore' };
158
+ switch (platform) {
159
+ case 'darwin':
160
+ command = 'open';
161
+ args = [url];
162
+ break;
163
+ case 'win32':
164
+ command = 'cmd';
165
+ args = ['/c', 'start', '""', `"${`${url}`.replaceAll('"', '""')}"`];
166
+ options = { detached: true, stdio: 'ignore', windowsHide: true, windowsVerbatimArguments: true };
167
+ break;
168
+ default:
169
+ command = 'xdg-open';
170
+ args = [url];
171
+ break;
172
+ }
173
+
174
+ return { command, args, options };
175
+ }
176
+
177
+ async function defaultOpenBrowser(url) {
178
+ const { command, args, options } = buildOpenBrowserInvocation(url);
179
+ await new Promise((resolve, reject) => {
180
+ const child = spawn(command, args, options);
181
+ child.once('error', reject);
182
+ child.once('spawn', () => {
183
+ child.unref();
184
+ resolve(undefined);
185
+ });
186
+ });
187
+ }
188
+
189
+ async function readManagedRecord(recordPath) {
190
+ try {
191
+ return JSON.parse(await readFile(recordPath, 'utf8'));
192
+ } catch (error) {
193
+ if (error?.code === 'ENOENT') {
194
+ return null;
195
+ }
196
+ if (error instanceof SyntaxError || error?.code === 'EISDIR') {
197
+ return {
198
+ invalidRecord: true,
199
+ parseError: error.message,
200
+ };
201
+ }
202
+ throw error;
203
+ }
204
+ }
205
+
206
+ async function writeManagedRecord(recordPath, payload) {
207
+ await mkdir(path.dirname(recordPath), { recursive: true });
208
+ await writeFile(recordPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
209
+ }
210
+
211
+ async function removeManagedRecord(recordPath) {
212
+ await rm(recordPath, { force: true, recursive: true });
213
+ }
214
+
215
+ async function stopManagedProcessSafely(pid, { stopManagedProcessImpl }) {
216
+ try {
217
+ await stopManagedProcessImpl(pid);
218
+ } catch (error) {
219
+ if (error?.code !== 'ESRCH') {
220
+ throw error;
221
+ }
222
+ }
223
+ }
224
+
225
+ async function waitForManagedExit(record, { isProcessAliveImpl, listListeningPidsImpl: listListeningPids, waitImpl, nowMsImpl, timeoutMs = 3000, pollIntervalMs = 100 }) {
226
+ if (!record?.pid) {
227
+ return true;
228
+ }
229
+ const deadline = nowMsImpl() + timeoutMs;
230
+ while (nowMsImpl() < deadline) {
231
+ const [alive, listeners] = await Promise.all([
232
+ isProcessAliveImpl(record.pid),
233
+ listListeningPids(record.port ?? DEFAULT_PORT),
234
+ ]);
235
+ if (!alive && !listeners.includes(record.pid)) {
236
+ return true;
237
+ }
238
+ await waitImpl(pollIntervalMs);
239
+ }
240
+
241
+ const [alive, listeners] = await Promise.all([
242
+ isProcessAliveImpl(record.pid),
243
+ listListeningPids(record.port ?? DEFAULT_PORT),
244
+ ]);
245
+ return !alive && !listeners.includes(record.pid);
246
+ }
247
+
248
+ async function reclaimPortListeners({
249
+ port,
250
+ listListeningPidsImpl: listListeningPids,
251
+ stopManagedProcessImpl,
252
+ waitImpl,
253
+ nowMsImpl,
254
+ timeoutMs = 1500,
255
+ pollIntervalMs = 50,
256
+ }) {
257
+ const listeners = (await listListeningPids(port)).filter((pid) => pid !== process.pid);
258
+ if (listeners.length === 0) {
259
+ return { cleared: true, remainingListeners: [] };
260
+ }
261
+
262
+ for (const pid of listeners) {
263
+ await stopManagedProcessSafely(pid, { stopManagedProcessImpl });
264
+ }
265
+
266
+ const deadline = nowMsImpl() + timeoutMs;
267
+ while (nowMsImpl() < deadline) {
268
+ const remainingListeners = (await listListeningPids(port)).filter((pid) => pid !== process.pid);
269
+ if (remainingListeners.length === 0) {
270
+ return { cleared: true, remainingListeners: [] };
271
+ }
272
+ await waitImpl(pollIntervalMs);
273
+ }
274
+
275
+ const remainingListeners = (await listListeningPids(port)).filter((pid) => pid !== process.pid);
276
+ return { cleared: remainingListeners.length === 0, remainingListeners };
277
+ }
278
+
279
+ function buildFailedStopResult({ record, recordPath, detail }) {
280
+ return {
281
+ state: 'stale_record',
282
+ url: baseUrlForRecord(record),
283
+ detail,
284
+ warning: null,
285
+ recordPath,
286
+ record,
287
+ };
288
+ }
289
+
290
+ function summarizeRunning(record, requestedRepo) {
291
+ return {
292
+ state: 'running',
293
+ url: buildOperatorUrl(record, requestedRepo),
294
+ detail: 'Managed inspect-run viewer is running.',
295
+ };
296
+ }
297
+
298
+ export function createInspectRunViewerLifecycleManager({
299
+ listListeningPidsImpl = listListeningPidsForPort,
300
+ isProcessAliveImpl = defaultIsProcessAlive,
301
+ healthcheckUrlImpl = defaultHealthcheck,
302
+ launchManagedServerImpl = defaultLaunchManagedServer,
303
+ stopManagedProcessImpl = defaultStopManagedProcess,
304
+ openBrowserImpl = defaultOpenBrowser,
305
+ nowImpl = () => new Date().toISOString(),
306
+ nowMsImpl = () => Date.now(),
307
+ waitImpl = sleep,
308
+ } = {}) {
309
+ async function listListeningPids(port) {
310
+ try {
311
+ return await listListeningPidsImpl(port);
312
+ } catch (error) {
313
+ throw normalizeListenerDiscoveryError(error);
314
+ }
315
+ }
316
+
317
+ async function inspectRecord({ repoRoot }) {
318
+ const recordPath = path.join(requireRepoRoot(repoRoot), INSPECT_RUN_VIEWER_MANAGED_RECORD_PATH);
319
+ const record = await readManagedRecord(recordPath);
320
+ if (record?.invalidRecord === true || (record !== null && !isManagedRecordShape(record))) {
321
+ return {
322
+ recordPath,
323
+ record: null,
324
+ state: 'stale_record',
325
+ url: formatInspectRunViewerUrl(DEFAULT_HOST, DEFAULT_PORT),
326
+ detail: 'The managed inspect-run viewer record is invalid or unreadable; delete `.pi/ui-servers/inspect-run-viewer.json` and reopen the viewer.',
327
+ listeners: await listListeningPids(DEFAULT_PORT),
328
+ };
329
+ }
330
+ if (!record) {
331
+ const listeners = await listListeningPids(DEFAULT_PORT);
332
+ if (listeners.length > 0) {
333
+ return {
334
+ recordPath,
335
+ record: null,
336
+ state: 'conflict_unmanaged_listener',
337
+ url: formatInspectRunViewerUrl(DEFAULT_HOST, DEFAULT_PORT),
338
+ detail: `Port ${DEFAULT_PORT} is occupied by an unmanaged listener.`,
339
+ listeners,
340
+ };
341
+ }
342
+ return {
343
+ recordPath,
344
+ record: null,
345
+ state: 'stopped',
346
+ url: null,
347
+ detail: 'No managed inspect-run viewer is recorded.',
348
+ listeners: [],
349
+ };
350
+ }
351
+
352
+ const listeners = await listListeningPids(record.port ?? DEFAULT_PORT);
353
+ const alive = typeof record.pid === 'number' && record.pid > 0
354
+ ? await isProcessAliveImpl(record.pid)
355
+ : false;
356
+ const listening = alive && listeners.includes(record.pid);
357
+ const healthy = listening ? await healthcheckUrlImpl(baseUrlForRecord(record)) : false;
358
+
359
+ if (alive && listening && healthy) {
360
+ return {
361
+ recordPath,
362
+ record,
363
+ state: 'running',
364
+ url: baseUrlForRecord(record),
365
+ detail: 'Managed inspect-run viewer is running.',
366
+ listeners,
367
+ };
368
+ }
369
+
370
+ return {
371
+ recordPath,
372
+ record,
373
+ state: 'stale_record',
374
+ url: baseUrlForRecord(record),
375
+ detail: 'The managed inspect-run viewer record is stale.',
376
+ listeners,
377
+ };
378
+ }
379
+
380
+ async function startFresh({ repoRoot, requestedRepo }) {
381
+ const launchArgs = buildLaunchArgs(requestedRepo);
382
+ const recordPath = path.join(repoRoot, INSPECT_RUN_VIEWER_MANAGED_RECORD_PATH);
383
+ const baseUrl = formatInspectRunViewerUrl(launchArgs.host, launchArgs.port);
384
+ const reclaimed = await reclaimPortListeners({
385
+ port: launchArgs.port,
386
+ listListeningPidsImpl: listListeningPids,
387
+ stopManagedProcessImpl,
388
+ waitImpl,
389
+ nowMsImpl,
390
+ });
391
+ if (!reclaimed.cleared) {
392
+ return {
393
+ state: 'conflict_unmanaged_listener',
394
+ url: baseUrl,
395
+ detail: `Port ${launchArgs.port} is occupied by an unmanaged listener that did not stop after SIGTERM.`,
396
+ warning: null,
397
+ recordPath,
398
+ };
399
+ }
400
+
401
+ const { pid } = await launchManagedServerImpl({
402
+ repoRoot,
403
+ repo: requestedRepo,
404
+ host: launchArgs.host,
405
+ port: launchArgs.port,
406
+ url: baseUrl,
407
+ });
408
+ if (!Number.isInteger(pid) || pid <= 0) {
409
+ throw new Error('inspect-run viewer launch must return a positive integer pid');
410
+ }
411
+ const record = buildRecordPayload({ repoRoot, launchArgs, pid, startedAt: nowImpl() });
412
+
413
+ try {
414
+ const deadline = nowMsImpl() + 8000;
415
+ while (nowMsImpl() < deadline) {
416
+ const [alive, healthy] = await Promise.all([
417
+ isProcessAliveImpl(pid),
418
+ healthcheckUrlImpl(baseUrl),
419
+ ]);
420
+ if (!alive) {
421
+ throw new Error('inspect-run viewer exited before becoming healthy');
422
+ }
423
+ if (healthy) {
424
+ await writeManagedRecord(recordPath, record);
425
+ return {
426
+ state: 'running',
427
+ url: buildOperatorUrl(record, requestedRepo),
428
+ detail: 'Started a managed inspect-run viewer.',
429
+ warning: null,
430
+ recordPath,
431
+ record,
432
+ startedFresh: true,
433
+ reusedExisting: false,
434
+ resumedExisting: false,
435
+ };
436
+ }
437
+ await waitImpl(100);
438
+ }
439
+ throw new Error('inspect-run viewer did not become healthy before the startup timeout');
440
+ } catch (error) {
441
+ if (await isProcessAliveImpl(pid)) {
442
+ await stopManagedProcessSafely(pid, { stopManagedProcessImpl });
443
+ }
444
+ await waitForManagedExit(record, {
445
+ isProcessAliveImpl,
446
+ listListeningPidsImpl: listListeningPids,
447
+ waitImpl,
448
+ nowMsImpl,
449
+ });
450
+ throw error;
451
+ }
452
+ }
453
+
454
+ async function maybeOpenBrowser(result) {
455
+ if (result.state !== 'running' || typeof result.url !== 'string' || result.url.length === 0) {
456
+ return result;
457
+ }
458
+ try {
459
+ await openBrowserImpl(result.url);
460
+ return result;
461
+ } catch (error) {
462
+ return {
463
+ ...result,
464
+ warning: error instanceof Error ? error.message : String(error),
465
+ };
466
+ }
467
+ }
468
+
469
+ return {
470
+ async open({ repoRoot, repo } = {}) {
471
+ const requestedRepo = normalizeRequestedRepo(repo);
472
+ const snapshot = await inspectRecord({ repoRoot });
473
+ if (snapshot.state === 'running' && canServeRequestedRepo(snapshot.record, requestedRepo)) {
474
+ return maybeOpenBrowser({
475
+ ...summarizeRunning(snapshot.record, requestedRepo),
476
+ warning: null,
477
+ recordPath: snapshot.recordPath,
478
+ record: snapshot.record,
479
+ startedFresh: false,
480
+ reusedExisting: true,
481
+ resumedExisting: false,
482
+ });
483
+ }
484
+
485
+ if (snapshot.state === 'running' && snapshot.record) {
486
+ await stopManagedProcessSafely(snapshot.record.pid, { stopManagedProcessImpl });
487
+ const exited = await waitForManagedExit(snapshot.record, {
488
+ isProcessAliveImpl,
489
+ listListeningPidsImpl: listListeningPids,
490
+ waitImpl,
491
+ nowMsImpl,
492
+ });
493
+ if (!exited) {
494
+ return {
495
+ ...buildFailedStopResult({
496
+ record: snapshot.record,
497
+ recordPath: snapshot.recordPath,
498
+ detail: 'Managed inspect-run viewer did not stop after SIGTERM; keeping the managed record instead of replacing it.',
499
+ }),
500
+ startedFresh: false,
501
+ reusedExisting: false,
502
+ resumedExisting: false,
503
+ };
504
+ }
505
+ await removeManagedRecord(snapshot.recordPath);
506
+ } else if (snapshot.state === 'stale_record') {
507
+ if (snapshot.record?.pid
508
+ && snapshot.listeners.includes(snapshot.record.pid)
509
+ && await isProcessAliveImpl(snapshot.record.pid)) {
510
+ await stopManagedProcessSafely(snapshot.record.pid, { stopManagedProcessImpl });
511
+ const exited = await waitForManagedExit(snapshot.record, {
512
+ isProcessAliveImpl,
513
+ listListeningPidsImpl: listListeningPids,
514
+ waitImpl,
515
+ nowMsImpl,
516
+ });
517
+ if (!exited) {
518
+ return {
519
+ ...buildFailedStopResult({
520
+ record: snapshot.record,
521
+ recordPath: snapshot.recordPath,
522
+ detail: 'Managed inspect-run viewer stayed bound to the port after SIGTERM; keeping the stale record for manual cleanup.',
523
+ }),
524
+ startedFresh: false,
525
+ reusedExisting: false,
526
+ resumedExisting: false,
527
+ };
528
+ }
529
+ }
530
+ await removeManagedRecord(snapshot.recordPath);
531
+ }
532
+
533
+ return maybeOpenBrowser(await startFresh({ repoRoot, requestedRepo }));
534
+ },
535
+
536
+ async resume({ repoRoot, repo } = {}) {
537
+ const requestedRepo = normalizeRequestedRepo(repo);
538
+ const snapshot = await inspectRecord({ repoRoot });
539
+ if (snapshot.state === 'running' && canServeRequestedRepo(snapshot.record, requestedRepo)) {
540
+ return {
541
+ ...summarizeRunning(snapshot.record, requestedRepo),
542
+ warning: null,
543
+ recordPath: snapshot.recordPath,
544
+ record: snapshot.record,
545
+ startedFresh: false,
546
+ reusedExisting: false,
547
+ resumedExisting: true,
548
+ };
549
+ }
550
+ return {
551
+ state: snapshot.state === 'conflict_unmanaged_listener' ? 'conflict_unmanaged_listener' : 'stopped',
552
+ url: snapshot.state === 'conflict_unmanaged_listener' ? snapshot.url : null,
553
+ detail: 'No managed inspect-run viewer is running; use `/dev-loops inspect open`.',
554
+ warning: null,
555
+ recordPath: snapshot.recordPath,
556
+ record: snapshot.record,
557
+ startedFresh: false,
558
+ reusedExisting: false,
559
+ resumedExisting: false,
560
+ };
561
+ },
562
+
563
+ async status({ repoRoot, repo } = {}) {
564
+ const requestedRepo = normalizeRequestedRepo(repo);
565
+ const snapshot = await inspectRecord({ repoRoot });
566
+ if (snapshot.state === 'running') {
567
+ if (!canServeRequestedRepo(snapshot.record, requestedRepo)) {
568
+ return {
569
+ state: 'stopped',
570
+ url: null,
571
+ detail: 'A different managed inspect-run viewer is running; use `open` to replace it for this repo.',
572
+ warning: null,
573
+ recordPath: snapshot.recordPath,
574
+ record: snapshot.record,
575
+ };
576
+ }
577
+ return {
578
+ ...summarizeRunning(snapshot.record, requestedRepo),
579
+ warning: null,
580
+ recordPath: snapshot.recordPath,
581
+ record: snapshot.record,
582
+ };
583
+ }
584
+ return {
585
+ state: snapshot.state,
586
+ url: snapshot.url,
587
+ detail: snapshot.detail,
588
+ warning: null,
589
+ recordPath: snapshot.recordPath,
590
+ record: snapshot.record,
591
+ };
592
+ },
593
+
594
+ async stop({ repoRoot, repo } = {}) {
595
+ const requestedRepo = normalizeRequestedRepo(repo);
596
+ const snapshot = await inspectRecord({ repoRoot });
597
+ if (snapshot.state === 'running' && snapshot.record && canServeRequestedRepo(snapshot.record, requestedRepo)) {
598
+ await stopManagedProcessSafely(snapshot.record.pid, { stopManagedProcessImpl });
599
+ const exited = await waitForManagedExit(snapshot.record, {
600
+ isProcessAliveImpl,
601
+ listListeningPidsImpl: listListeningPids,
602
+ waitImpl,
603
+ nowMsImpl,
604
+ });
605
+ if (!exited) {
606
+ return buildFailedStopResult({
607
+ record: snapshot.record,
608
+ recordPath: snapshot.recordPath,
609
+ detail: 'Managed inspect-run viewer did not stop after SIGTERM; keeping the managed record.',
610
+ });
611
+ }
612
+ await removeManagedRecord(snapshot.recordPath);
613
+ const listeners = await listListeningPids(snapshot.record.port ?? DEFAULT_PORT);
614
+ if (listeners.length > 0) {
615
+ return {
616
+ state: 'conflict_unmanaged_listener',
617
+ url: baseUrlForRecord(snapshot.record),
618
+ detail: 'Stopped the managed inspect-run viewer, but another listener is still using the port.',
619
+ warning: null,
620
+ recordPath: snapshot.recordPath,
621
+ record: null,
622
+ };
623
+ }
624
+ return {
625
+ state: 'stopped',
626
+ url: null,
627
+ detail: 'Stopped the managed inspect-run viewer.',
628
+ warning: null,
629
+ recordPath: snapshot.recordPath,
630
+ record: null,
631
+ };
632
+ }
633
+
634
+ if (snapshot.state === 'running' && snapshot.record && !canServeRequestedRepo(snapshot.record, requestedRepo)) {
635
+ return {
636
+ state: 'stopped',
637
+ url: null,
638
+ detail: 'A different managed inspect-run viewer is running; stop without `--repo` or use `open` to replace it for this repo.',
639
+ warning: null,
640
+ recordPath: snapshot.recordPath,
641
+ record: snapshot.record,
642
+ };
643
+ }
644
+
645
+ if (snapshot.state === 'stale_record') {
646
+ if (snapshot.record?.pid
647
+ && snapshot.listeners.includes(snapshot.record.pid)
648
+ && await isProcessAliveImpl(snapshot.record.pid)) {
649
+ await stopManagedProcessSafely(snapshot.record.pid, { stopManagedProcessImpl });
650
+ const exited = await waitForManagedExit(snapshot.record, {
651
+ isProcessAliveImpl,
652
+ listListeningPidsImpl: listListeningPids,
653
+ waitImpl,
654
+ nowMsImpl,
655
+ });
656
+ if (!exited) {
657
+ return buildFailedStopResult({
658
+ record: snapshot.record,
659
+ recordPath: snapshot.recordPath,
660
+ detail: 'Managed inspect-run viewer stayed bound to the port after SIGTERM; keeping the stale record for manual cleanup.',
661
+ });
662
+ }
663
+ }
664
+ await removeManagedRecord(snapshot.recordPath);
665
+ const listeners = await listListeningPids(snapshot.record?.port ?? DEFAULT_PORT);
666
+ return {
667
+ state: listeners.length > 0 ? 'conflict_unmanaged_listener' : 'stopped',
668
+ url: listeners.length > 0 ? baseUrlForRecord(snapshot.record) : null,
669
+ detail: listeners.length > 0
670
+ ? 'Cleared the stale managed record, but the port is still occupied by an unmanaged listener.'
671
+ : 'Cleared the stale managed inspect-run viewer record.',
672
+ warning: null,
673
+ recordPath: snapshot.recordPath,
674
+ record: null,
675
+ };
676
+ }
677
+
678
+ return {
679
+ state: snapshot.state,
680
+ url: snapshot.state === 'conflict_unmanaged_listener' ? snapshot.url : null,
681
+ detail: snapshot.detail,
682
+ warning: null,
683
+ recordPath: snapshot.recordPath,
684
+ record: null,
685
+ };
686
+ },
687
+
688
+ async restart({ repoRoot, repo } = {}) {
689
+ const requestedRepo = normalizeRequestedRepo(repo);
690
+ const snapshot = await inspectRecord({ repoRoot });
691
+ const restartRepo = snapshot.record?.launchArgs?.repo ?? requestedRepo;
692
+
693
+ if (snapshot.state === 'running' && snapshot.record) {
694
+ if (requestedRepo !== null && !canServeRequestedRepo(snapshot.record, requestedRepo)) {
695
+ return {
696
+ state: 'stopped',
697
+ url: null,
698
+ detail: 'Restart uses the managed instance arguments; stop/open to switch repos.',
699
+ warning: null,
700
+ recordPath: snapshot.recordPath,
701
+ record: snapshot.record,
702
+ };
703
+ }
704
+ await stopManagedProcessSafely(snapshot.record.pid, { stopManagedProcessImpl });
705
+ const exited = await waitForManagedExit(snapshot.record, {
706
+ isProcessAliveImpl,
707
+ listListeningPidsImpl: listListeningPids,
708
+ waitImpl,
709
+ nowMsImpl,
710
+ });
711
+ if (!exited) {
712
+ return buildFailedStopResult({
713
+ record: snapshot.record,
714
+ recordPath: snapshot.recordPath,
715
+ detail: 'Managed inspect-run viewer did not stop after SIGTERM; keeping the managed record instead of restarting it.',
716
+ });
717
+ }
718
+ await removeManagedRecord(snapshot.recordPath);
719
+ } else if (snapshot.state === 'stale_record') {
720
+ if (snapshot.record?.pid
721
+ && snapshot.listeners.includes(snapshot.record.pid)
722
+ && await isProcessAliveImpl(snapshot.record.pid)) {
723
+ await stopManagedProcessSafely(snapshot.record.pid, { stopManagedProcessImpl });
724
+ const exited = await waitForManagedExit(snapshot.record, {
725
+ isProcessAliveImpl,
726
+ listListeningPidsImpl: listListeningPids,
727
+ waitImpl,
728
+ nowMsImpl,
729
+ });
730
+ if (!exited) {
731
+ return buildFailedStopResult({
732
+ record: snapshot.record,
733
+ recordPath: snapshot.recordPath,
734
+ detail: 'Managed inspect-run viewer stayed bound to the port after SIGTERM; keeping the stale record for manual cleanup.',
735
+ });
736
+ }
737
+ }
738
+ await removeManagedRecord(snapshot.recordPath);
739
+ }
740
+
741
+ const restarted = await startFresh({ repoRoot, requestedRepo: restartRepo });
742
+ return restarted.state === 'running'
743
+ ? {
744
+ ...restarted,
745
+ detail: 'Restarted the managed inspect-run viewer.',
746
+ }
747
+ : restarted;
748
+ },
749
+ };
750
+ }