@moda-ai/cli 1.17.1 → 1.18.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.
@@ -176,7 +176,7 @@ export function renderAnalystTuiFrame(state, options = {}) {
176
176
  .map(([name, count]) => `${name} ${count}`)
177
177
  .join(', ') || 'none yet';
178
178
  const lines = [];
179
- lines.push(`${bold('Moda Analyst')} ${dim('Step 4/7 Harness Discovery')}`);
179
+ lines.push(`${bold('Moda Analyst')} ${dim('Step 4/5 Set up')}`);
180
180
  lines.push(rule(width));
181
181
  lines.push(`${spinnerText} ${state.adapterName} ${dim('read-only repository access')} ${dim(`elapsed ${formatDuration(elapsedSeconds)}`)} ${dim(state.sawOutput ? `last output ${formatDuration(quietForSeconds)} ago` : 'waiting for first output')}`);
182
182
  lines.push('');
package/dist/cli.js CHANGED
@@ -137,6 +137,7 @@ Commands:
137
137
  keys create Create a local key descriptor
138
138
  config show Show resolved Moda config
139
139
  harness analyze Produce a cited structured harness report
140
+ harness pull Fetch a server-side analyze run (status, or result when done)
140
141
  harness validate-report Validate .moda/harness-report.json citations
141
142
  harness approve Approve a validated harness report for sync
142
143
  harness scan Discover local runtime agents and write .moda/harness.json
@@ -273,6 +274,7 @@ Examples:
273
274
  moda harness analyze --analyst=claude --no-tui
274
275
  moda harness analyze --analyst=claude --summary
275
276
  moda harness analyze --analyst=local-scan --summary
277
+ moda harness pull --sync
276
278
  moda harness validate-report
277
279
  moda harness approve --yes
278
280
  moda harness sync --from-report .moda/harness-report.json
@@ -872,6 +874,7 @@ export const commandRegistry = createCommandRegistry([
872
874
  examples: [
873
875
  'moda harness analyze --summary',
874
876
  'moda harness analyze --remote',
877
+ 'moda harness pull --sync',
875
878
  'moda harness approve --yes',
876
879
  'moda harness sync --from-report .moda/harness-report.json',
877
880
  'moda harness explain --agent=agent_support',
@@ -6,15 +6,101 @@
6
6
  * (`POST /v1/harness/analyze`), polls `GET /v1/harness/analyze/:id` for the
7
7
  * validated report, then writes the same local artifacts and emits the same
8
8
  * lifecycle events as the local analyze paths.
9
+ *
10
+ * The flow is split into composable pieces so callers can overlap or detach it:
11
+ * `startRemoteAnalyze` (snapshot + upload + POST), `pollRemoteAnalyzeUntilDone`
12
+ * (status loop with an `onProgress` hook), and `finalizeRemoteAnalyzeResult`
13
+ * (repair + local validation + artifact writes). `runRemoteHarnessAnalyst`
14
+ * composes all three for `moda harness analyze --remote`, and `moda harness
15
+ * pull` (runHarnessPull) resumes a detached run from the
16
+ * `.moda/harness-remote-run.json` state file.
9
17
  */
10
18
  import type { CommandContext } from './command-context.js';
11
19
  import { type HarnessCommandScanPlan, type HarnessTenantContext } from './harness.js';
20
+ /** Per-repo pointer to the most recent server-side analysis run. */
21
+ export declare const HARNESS_REMOTE_RUN_STATE_PATH: string;
22
+ /**
23
+ * Small state file written on start and on terminal states so `moda harness
24
+ * pull` and `moda harness status` can find a run after the starting process
25
+ * exited. Snake_case keys match the wire contract.
26
+ */
27
+ export interface HarnessRemoteRunState {
28
+ run_id: string;
29
+ started_at_iso: string;
30
+ status: 'running' | 'completed' | 'failed';
31
+ }
32
+ /**
33
+ * Optional intermediate progress attached by newer hosted runners to
34
+ * `accepted`/`running` status responses. Older servers omit it.
35
+ */
36
+ export interface RemoteAnalyzeProgress {
37
+ phase?: string;
38
+ message?: string;
39
+ tool_calls?: number;
40
+ updated_ms?: number;
41
+ }
42
+ export interface RemoteAnalyzeStatus {
43
+ success: boolean;
44
+ status: 'accepted' | 'running' | 'completed' | 'failed';
45
+ report?: unknown;
46
+ validation?: unknown;
47
+ error?: string;
48
+ progress?: RemoteAnalyzeProgress;
49
+ }
12
50
  /**
13
51
  * True when `moda harness analyze` should run server-side (Moda-hosted) instead
14
52
  * of spawning a local coding agent: `--remote` (flags.remote === 'true') or the
15
53
  * `MODA_HARNESS_REMOTE` env opt-in.
16
54
  */
17
55
  export declare function isRemoteHarnessAnalyze(context: CommandContext): boolean;
56
+ /** Read `.moda/harness-remote-run.json`; undefined when absent or unparseable. */
57
+ export declare function readRemoteRunState(rootDir: string): HarnessRemoteRunState | undefined;
58
+ /**
59
+ * Snapshot + upload + POST. Emits the inventory/upload lifecycle events,
60
+ * persists `.moda/harness-remote-run.json` with status `running`, and returns
61
+ * the accepted run id without waiting for the analysis.
62
+ */
63
+ export declare function startRemoteAnalyze(options: {
64
+ context: CommandContext;
65
+ rootDir: string;
66
+ }): Promise<{
67
+ runId: string;
68
+ snapshotTruncated: boolean;
69
+ }>;
70
+ /**
71
+ * Poll `GET /v1/harness/analyze/:id` until the run is terminal, the deadline
72
+ * passes, or `signal` aborts. `onProgress` fires after every poll with the
73
+ * latest status and any server-attached progress, so callers can render a live
74
+ * board; the poll cadence (3s doubling to 15s) is unchanged.
75
+ */
76
+ export declare function pollRemoteAnalyzeUntilDone(options: {
77
+ context: CommandContext;
78
+ runId: string;
79
+ onProgress?: (update: {
80
+ status: RemoteAnalyzeStatus['status'];
81
+ progress?: RemoteAnalyzeProgress;
82
+ }) => void;
83
+ timeoutMs?: number;
84
+ signal?: AbortSignal;
85
+ }): Promise<RemoteAnalyzeStatus>;
86
+ /**
87
+ * Turn a terminal (or timed-out) status payload into the local result: repair
88
+ * the report from local source, re-validate, write `.moda` artifacts, emit the
89
+ * artifact/validation events, and update the run state file. Shared by
90
+ * `moda harness analyze --remote` and `moda harness pull`. Throws for runs the
91
+ * client cannot recover; a timed-out non-terminal run keeps the state file at
92
+ * `running` so `moda harness pull` can resume it later.
93
+ */
94
+ export declare function finalizeRemoteAnalyzeResult(options: {
95
+ context: CommandContext;
96
+ rootDir: string;
97
+ scanPlan: HarnessCommandScanPlan;
98
+ runId: string;
99
+ final: RemoteAnalyzeStatus;
100
+ snapshotTruncated?: boolean;
101
+ }): {
102
+ exitCode: number;
103
+ };
18
104
  /**
19
105
  * Server-side ("Moda-hosted") harness analysis. Builds a safe source snapshot,
20
106
  * uploads it to the ingest worker, polls for the validated report, then writes
@@ -30,4 +116,20 @@ export declare function runRemoteHarnessAnalyst(options: {
30
116
  }): Promise<{
31
117
  exitCode: number;
32
118
  }>;
119
+ /**
120
+ * `moda harness pull [--run-id=<id>] [--sync]`: fetch the status of a
121
+ * server-side analysis run once (default run id from
122
+ * `.moda/harness-remote-run.json`). Still running: print status + progress and
123
+ * exit 0. Completed: finalize exactly like `harness analyze --remote` (repair,
124
+ * validate, write `.moda` artifacts); with `--sync` also approve + `harness
125
+ * sync --from-report` best-effort, the way `moda init` does. Failed: error,
126
+ * exit 1.
127
+ */
128
+ export declare function runHarnessPull(options: {
129
+ context: CommandContext;
130
+ rootDir: string;
131
+ scanPlan: HarnessCommandScanPlan;
132
+ }): Promise<{
133
+ exitCode: number;
134
+ }>;
33
135
  //# sourceMappingURL=harness-remote.d.ts.map
@@ -6,14 +6,26 @@
6
6
  * (`POST /v1/harness/analyze`), polls `GET /v1/harness/analyze/:id` for the
7
7
  * validated report, then writes the same local artifacts and emits the same
8
8
  * lifecycle events as the local analyze paths.
9
+ *
10
+ * The flow is split into composable pieces so callers can overlap or detach it:
11
+ * `startRemoteAnalyze` (snapshot + upload + POST), `pollRemoteAnalyzeUntilDone`
12
+ * (status loop with an `onProgress` hook), and `finalizeRemoteAnalyzeResult`
13
+ * (repair + local validation + artifact writes). `runRemoteHarnessAnalyst`
14
+ * composes all three for `moda harness analyze --remote`, and `moda harness
15
+ * pull` (runHarnessPull) resumes a detached run from the
16
+ * `.moda/harness-remote-run.json` state file.
9
17
  */
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { createCommandContext, stringOption } from './command-context.js';
10
21
  import { resolveApiKey, resolveIngestUrl } from './config.js';
11
22
  import { CliInputError } from './errors.js';
23
+ import { stripAnsi } from './markdown-terminal.js';
12
24
  import { normalizeHarnessReport, stampRemoteSnapshotTruncation, validateHarnessReport, writeHarnessReportArtifacts, } from './harness-report.js';
13
- import { buildSourceSnapshot, emitWrittenArtifacts, numericEnv, numericEnvAllowZero, repairExternalAnalystReport, sleep, summarizeHarness, summarizeInvalidAnalystReport, truncateText, writeHarnessAnalyzeResult, writeHarnessArtifacts, writeHumanProgress, } from './harness.js';
25
+ import { buildSourceSnapshot, emitWrittenArtifacts, numericEnv, numericEnvAllowZero, repairExternalAnalystReport, runHarnessCommand, sleep, summarizeHarness, summarizeInvalidAnalystReport, truncateText, writeHarnessAnalyzeResult, writeHarnessArtifacts, writeHumanProgress, } from './harness.js';
14
26
  /**
15
27
  * Client poll deadline. Must exceed the server's total analysis budget
16
- * (MODA_HARNESS_ANALYZE_TOTAL_BUDGET_MS, default 30m — full attempts plus the
28
+ * (MODA_HARNESS_ANALYZE_TOTAL_BUDGET_MS, default 30m: full attempts plus the
17
29
  * sharded fallback), or the client reports a bogus "did not finish" while the
18
30
  * hosted run is still working; polling also keeps the hosted container awake.
19
31
  */
@@ -22,8 +34,17 @@ const DEFAULT_REMOTE_POLL_INITIAL_MS = 3_000;
22
34
  const DEFAULT_REMOTE_POLL_MAX_MS = 15_000;
23
35
  /** Lower bound on the inter-poll delay so a 0 interval can't busy-loop the status endpoint. */
24
36
  const MIN_REMOTE_POLL_DELAY_MS = 250;
37
+ /**
38
+ * Consecutive status-poll failures tolerated before the poll gives up. A
39
+ * hosted run stays healthy across a transient blip (a network hiccup, a proxy
40
+ * 5xx, or a GET racing the container's run-state write), and aborting a
41
+ * 30-minute analysis over one failed GET would waste the whole run.
42
+ */
43
+ const REMOTE_POLL_MAX_CONSECUTIVE_FAILURES = 4;
25
44
  const REMOTE_UPLOAD_TIMEOUT_MS = 120_000;
26
45
  const REMOTE_STATUS_TIMEOUT_MS = 30_000;
46
+ /** Per-repo pointer to the most recent server-side analysis run. */
47
+ export const HARNESS_REMOTE_RUN_STATE_PATH = join('.moda', 'harness-remote-run.json');
27
48
  /**
28
49
  * True when `moda harness analyze` should run server-side (Moda-hosted) instead
29
50
  * of spawning a local coding agent: `--remote` (flags.remote === 'true') or the
@@ -34,8 +55,8 @@ export function isRemoteHarnessAnalyze(context) {
34
55
  // as the number 1 rather than the string '1'; accept every truthy spelling.
35
56
  const flag = context.flags.remote;
36
57
  // An explicit local opt-out (`--local` / `--no-remote` / `--remote=false|0`)
37
- // wins over the `MODA_HARNESS_REMOTE` env, so callers that force local — the
38
- // `moda init` local path and its remote-failure fallback — stay local even
58
+ // wins over the `MODA_HARNESS_REMOTE` env, so callers that force local (the
59
+ // `moda init` local path and its remote-failure fallback) stay local even
39
60
  // when the env is set. `no-remote` may arrive kebab- or snake-cased depending
40
61
  // on the caller (real CLI parsing snake-cases; internal callers do not).
41
62
  const noRemote = context.flags['no-remote'] ?? context.flags.no_remote;
@@ -49,23 +70,66 @@ export function isRemoteHarnessAnalyze(context) {
49
70
  const env = (context.env.MODA_HARNESS_REMOTE ?? '').trim().toLowerCase();
50
71
  return env !== '' && env !== '0' && env !== 'false';
51
72
  }
52
- /**
53
- * Server-side ("Moda-hosted") harness analysis. Builds a safe source snapshot,
54
- * uploads it to the ingest worker, polls for the validated report, then writes
55
- * the same local artifacts the local analyze path writes. Emits the same
56
- * lifecycle events so `--agent --stream` behaves consistently with the local
57
- * path.
58
- */
59
- export async function runRemoteHarnessAnalyst(options) {
60
- const { context, rootDir } = options;
61
- if (context.dryRun) {
62
- throw new CliInputError('Remote harness analysis cannot run in dry-run mode.', 'Use `moda harness analyze --analyst=local-scan --dry-run` for deterministic local inspection.');
73
+ /** Read `.moda/harness-remote-run.json`; undefined when absent or unparseable. */
74
+ export function readRemoteRunState(rootDir) {
75
+ const path = join(rootDir, HARNESS_REMOTE_RUN_STATE_PATH);
76
+ if (!existsSync(path))
77
+ return undefined;
78
+ try {
79
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
80
+ if (!parsed || typeof parsed.run_id !== 'string' || parsed.run_id.length === 0)
81
+ return undefined;
82
+ return {
83
+ run_id: parsed.run_id,
84
+ started_at_iso: typeof parsed.started_at_iso === 'string' ? parsed.started_at_iso : '',
85
+ status: parsed.status === 'completed' || parsed.status === 'failed' ? parsed.status : 'running',
86
+ };
87
+ }
88
+ catch {
89
+ return undefined;
63
90
  }
91
+ }
92
+ /** Best-effort write: run tracking must never break the analyze pipeline itself. */
93
+ function writeRemoteRunState(rootDir, state) {
94
+ try {
95
+ mkdirSync(join(rootDir, '.moda'), { recursive: true });
96
+ writeFileSync(join(rootDir, HARNESS_REMOTE_RUN_STATE_PATH), `${JSON.stringify(state, null, 2)}\n`);
97
+ }
98
+ catch {
99
+ // Ignore: a read-only tree loses run tracking, not the analysis result.
100
+ }
101
+ }
102
+ /** Mark the run terminal, preserving the original started_at when it is the same run. */
103
+ function markRemoteRunTerminal(rootDir, runId, status) {
104
+ const existing = readRemoteRunState(rootDir);
105
+ writeRemoteRunState(rootDir, {
106
+ run_id: runId,
107
+ started_at_iso: existing && existing.run_id === runId && existing.started_at_iso
108
+ ? existing.started_at_iso
109
+ : new Date().toISOString(),
110
+ status,
111
+ });
112
+ }
113
+ /** Resolve the ingest endpoint + API key or fail with the standard remote-analyze guidance. */
114
+ function resolveRemoteAnalyzeEndpoint(context) {
64
115
  const apiKey = resolveApiKey({ profile: context.profile, env: context.env });
65
116
  if (!apiKey) {
66
117
  throw new CliInputError('No Moda API key found for remote harness analysis.', 'Run `moda init` first (or set MODA_API_KEY), then rerun `moda harness analyze --remote`.');
67
118
  }
68
119
  const ingestUrl = resolveIngestUrl({ profile: context.profile, env: context.env }).replace(/\/+$/, '');
120
+ return { apiKey, ingestUrl };
121
+ }
122
+ /**
123
+ * Snapshot + upload + POST. Emits the inventory/upload lifecycle events,
124
+ * persists `.moda/harness-remote-run.json` with status `running`, and returns
125
+ * the accepted run id without waiting for the analysis.
126
+ */
127
+ export async function startRemoteAnalyze(options) {
128
+ const { context, rootDir } = options;
129
+ if (context.dryRun) {
130
+ throw new CliInputError('Remote harness analysis cannot run in dry-run mode.', 'Use `moda harness analyze --analyst=local-scan --dry-run` for deterministic local inspection.');
131
+ }
132
+ const { apiKey, ingestUrl } = resolveRemoteAnalyzeEndpoint(context);
69
133
  context.output.writeEvent({
70
134
  event: 'progress',
71
135
  phase: 'inventory',
@@ -88,13 +152,18 @@ export async function runRemoteHarnessAnalyst(options) {
88
152
  message: 'Source snapshot is truncated; server-side analysis may be incomplete.',
89
153
  elapsed_ms: Date.now() - context.startedAt,
90
154
  });
91
- writeHumanProgress(context, 'Warning: source snapshot is truncated — analysis may be incomplete.');
155
+ writeHumanProgress(context, 'Warning: source snapshot is truncated; analysis may be incomplete.');
92
156
  }
93
157
  const accepted = await postRemoteAnalyze(ingestUrl, apiKey, {
94
158
  snapshot,
95
159
  options: buildRemoteAnalyzeOptions(context.flags),
96
160
  });
97
161
  const runId = accepted.analysis_run_id;
162
+ writeRemoteRunState(rootDir, {
163
+ run_id: runId,
164
+ started_at_iso: new Date().toISOString(),
165
+ status: 'running',
166
+ });
98
167
  context.output.writeEvent({
99
168
  event: 'progress',
100
169
  phase: 'remote_wait',
@@ -102,26 +171,104 @@ export async function runRemoteHarnessAnalyst(options) {
102
171
  elapsed_ms: Date.now() - context.startedAt,
103
172
  });
104
173
  writeHumanProgress(context, `Moda Analyst (remote): analysis run ${runId} accepted; waiting for results...`);
105
- const final = await pollRemoteAnalyze({ ingestUrl, apiKey, runId, context });
174
+ return { runId, snapshotTruncated: snapshot.truncated };
175
+ }
176
+ /**
177
+ * Poll `GET /v1/harness/analyze/:id` until the run is terminal, the deadline
178
+ * passes, or `signal` aborts. `onProgress` fires after every poll with the
179
+ * latest status and any server-attached progress, so callers can render a live
180
+ * board; the poll cadence (3s doubling to 15s) is unchanged.
181
+ */
182
+ export async function pollRemoteAnalyzeUntilDone(options) {
183
+ const { context, runId, onProgress, signal } = options;
184
+ const { apiKey, ingestUrl } = resolveRemoteAnalyzeEndpoint(context);
185
+ const timeoutMs = options.timeoutMs ?? numericEnv('MODA_HARNESS_REMOTE_TIMEOUT_MS', DEFAULT_REMOTE_ANALYZE_TIMEOUT_MS);
186
+ const initialMs = numericEnvAllowZero('MODA_HARNESS_REMOTE_POLL_INITIAL_MS', DEFAULT_REMOTE_POLL_INITIAL_MS);
187
+ const maxMs = numericEnvAllowZero('MODA_HARNESS_REMOTE_POLL_MAX_MS', DEFAULT_REMOTE_POLL_MAX_MS);
188
+ const deadline = Date.now() + timeoutMs;
189
+ const url = `${ingestUrl}/v1/harness/analyze/${encodeURIComponent(runId)}`;
190
+ // Floor the inter-poll delay so a 0 (or unset) interval can't turn a long
191
+ // hosted run into a tight loop hammering the status endpoint.
192
+ const capMs = Math.max(maxMs, MIN_REMOTE_POLL_DELAY_MS);
193
+ let delay = Math.min(Math.max(initialMs, MIN_REMOTE_POLL_DELAY_MS), capMs);
194
+ let last = { success: false, status: 'accepted' };
195
+ let consecutiveFailures = 0;
196
+ while (true) {
197
+ try {
198
+ last = await remoteAnalyzeFetch(url, apiKey, { method: 'GET' }, REMOTE_STATUS_TIMEOUT_MS);
199
+ consecutiveFailures = 0;
200
+ }
201
+ catch (error) {
202
+ // Tolerate a bounded run of consecutive poll failures instead of
203
+ // aborting the (still healthy) hosted analysis on the first blip.
204
+ consecutiveFailures += 1;
205
+ if (consecutiveFailures >= REMOTE_POLL_MAX_CONSECUTIVE_FAILURES || signal?.aborted || Date.now() >= deadline) {
206
+ throw error;
207
+ }
208
+ context.output.writeEvent({
209
+ event: 'progress',
210
+ phase: 'remote_wait',
211
+ message: `Status poll failed (${consecutiveFailures}/${REMOTE_POLL_MAX_CONSECUTIVE_FAILURES}); retrying.`,
212
+ elapsed_ms: Date.now() - context.startedAt,
213
+ });
214
+ await sleep(Math.min(delay, Math.max(deadline - Date.now(), MIN_REMOTE_POLL_DELAY_MS)));
215
+ if (signal?.aborted)
216
+ return last;
217
+ delay = Math.min(delay * 2, capMs);
218
+ continue;
219
+ }
220
+ onProgress?.({ status: last?.status ?? 'running', progress: normalizeRemoteProgress(last?.progress) });
221
+ if (last?.status === 'completed' || last?.status === 'failed')
222
+ return last;
223
+ const remaining = deadline - Date.now();
224
+ if (remaining <= 0)
225
+ return last ?? { success: false, status: 'running' };
226
+ if (signal?.aborted)
227
+ return last ?? { success: false, status: 'running' };
228
+ context.output.writeEvent({
229
+ event: 'progress',
230
+ phase: 'remote_wait',
231
+ message: `Server-side analysis status: ${last?.status ?? 'running'}.`,
232
+ elapsed_ms: Date.now() - context.startedAt,
233
+ });
234
+ await sleep(Math.min(delay, remaining));
235
+ if (signal?.aborted)
236
+ return last;
237
+ delay = Math.min(delay * 2, capMs);
238
+ }
239
+ }
240
+ /**
241
+ * Turn a terminal (or timed-out) status payload into the local result: repair
242
+ * the report from local source, re-validate, write `.moda` artifacts, emit the
243
+ * artifact/validation events, and update the run state file. Shared by
244
+ * `moda harness analyze --remote` and `moda harness pull`. Throws for runs the
245
+ * client cannot recover; a timed-out non-terminal run keeps the state file at
246
+ * `running` so `moda harness pull` can resume it later.
247
+ */
248
+ export function finalizeRemoteAnalyzeResult(options) {
249
+ const { context, rootDir, runId, final } = options;
106
250
  // A run that returned no report at all is a hard failure (timeout, or the
107
- // hosted agent crashed before producing anything) — nothing to recover.
251
+ // hosted agent crashed before producing anything); nothing to recover.
108
252
  if (!final.report) {
253
+ if (final.status === 'failed')
254
+ markRemoteRunTerminal(rootDir, runId, 'failed');
109
255
  throw remoteAnalyzeHardFailure(final, runId);
110
256
  }
111
257
  // A `failed` run only qualifies for client-side recovery when the server tells
112
258
  // us the failure was its own REPORT VALIDATION (it produced a report but the
113
259
  // report-contract check failed). That is the one class the client can recover:
114
260
  // the uploaded snapshot is truncated/sampled, so the hosted analyst commonly
115
- // leaves prompt/tool `body` (or citation lines) empty, and the client — which
116
- // has the full local repo — can backfill those source-dependent gaps and
261
+ // leaves prompt/tool `body` (or citation lines) empty, and the client (which
262
+ // has the full local repo) can backfill those source-dependent gaps and
117
263
  // re-validate. Any other `failed` run (a crashed/aborted agent, which carries
118
264
  // an `error` string and no structured validation verdict) stays a hard failure
119
265
  // even if a partial report leaked through, so we never write or sync a report
120
266
  // from a hosted run the server aborted for a reason the client cannot repair.
121
267
  if (final.status === 'failed' && !serverFailedReportValidation(final)) {
268
+ markRemoteRunTerminal(rootDir, runId, 'failed');
122
269
  throw remoteAnalyzeHardFailure(final, runId);
123
270
  }
124
- // The hosted analyst returned a report that failed SERVER-side validation —
271
+ // The hosted analyst returned a report that failed SERVER-side validation,
125
272
  // most often because it enumerated prompt/tool artifacts but left their `body`
126
273
  // empty, or because the uploaded snapshot was truncated so the server never
127
274
  // saw the source. The client has the full local repo, so `repairExternalAnalystReport`
@@ -133,7 +280,7 @@ export async function runRemoteHarnessAnalyst(options) {
133
280
  writeHumanProgress(context, 'Moda Analyst (remote): server validation incomplete; completing artifact bodies from local source...');
134
281
  }
135
282
  let report = repairExternalAnalystReport(normalizeHarnessReport(final.report), rootDir);
136
- if (snapshot.truncated) {
283
+ if (options.snapshotTruncated) {
137
284
  report = stampRemoteSnapshotTruncation(report);
138
285
  }
139
286
  const validation = validateHarnessReport(report, rootDir);
@@ -158,6 +305,7 @@ export async function runRemoteHarnessAnalyst(options) {
158
305
  }));
159
306
  }
160
307
  emitWrittenArtifacts(context, written, 'Written by Moda Analyst (remote).');
308
+ markRemoteRunTerminal(rootDir, runId, validation.status === 'pass' ? 'completed' : 'failed');
161
309
  writeHarnessAnalyzeResult(context, {
162
310
  scanPlan: options.scanPlan,
163
311
  summary: validation.status === 'pass'
@@ -170,6 +318,117 @@ export async function runRemoteHarnessAnalyst(options) {
170
318
  });
171
319
  return { exitCode: validation.status === 'pass' ? 0 : 1 };
172
320
  }
321
+ /**
322
+ * Server-side ("Moda-hosted") harness analysis. Builds a safe source snapshot,
323
+ * uploads it to the ingest worker, polls for the validated report, then writes
324
+ * the same local artifacts the local analyze path writes. Emits the same
325
+ * lifecycle events so `--agent --stream` behaves consistently with the local
326
+ * path.
327
+ */
328
+ export async function runRemoteHarnessAnalyst(options) {
329
+ const { context, rootDir } = options;
330
+ const { runId, snapshotTruncated } = await startRemoteAnalyze({ context, rootDir });
331
+ const final = await pollRemoteAnalyzeUntilDone({ context, runId });
332
+ return finalizeRemoteAnalyzeResult({
333
+ context,
334
+ rootDir,
335
+ scanPlan: options.scanPlan,
336
+ runId,
337
+ final,
338
+ snapshotTruncated,
339
+ });
340
+ }
341
+ /**
342
+ * `moda harness pull [--run-id=<id>] [--sync]`: fetch the status of a
343
+ * server-side analysis run once (default run id from
344
+ * `.moda/harness-remote-run.json`). Still running: print status + progress and
345
+ * exit 0. Completed: finalize exactly like `harness analyze --remote` (repair,
346
+ * validate, write `.moda` artifacts); with `--sync` also approve + `harness
347
+ * sync --from-report` best-effort, the way `moda init` does. Failed: error,
348
+ * exit 1.
349
+ */
350
+ export async function runHarnessPull(options) {
351
+ const { context, rootDir } = options;
352
+ const state = readRemoteRunState(rootDir);
353
+ const flagRunId = stringOption(context.flags['run-id'] ?? context.flags.run_id);
354
+ const runId = flagRunId ?? state?.run_id;
355
+ if (!runId) {
356
+ throw new CliInputError('No server-side harness analysis run to pull.', 'Start one with `moda harness analyze --remote` (or `moda init`), or pass --run-id=<id>.');
357
+ }
358
+ const { apiKey, ingestUrl } = resolveRemoteAnalyzeEndpoint(context);
359
+ const url = `${ingestUrl}/v1/harness/analyze/${encodeURIComponent(runId)}`;
360
+ const status = await remoteAnalyzeFetch(url, apiKey, { method: 'GET' }, REMOTE_STATUS_TIMEOUT_MS);
361
+ if (status?.status === 'accepted' || status?.status === 'running') {
362
+ const progress = normalizeRemoteProgress(status.progress);
363
+ writeHumanProgress(context, `Moda Analyst (remote): run ${runId} is still ${status.status}${progress?.message ? ` (${progress.message})` : ''}.`);
364
+ context.output.writeData({
365
+ runId,
366
+ status: status.status,
367
+ progress,
368
+ startedAt: state && state.run_id === runId && state.started_at_iso ? state.started_at_iso : undefined,
369
+ hint: 'Run `moda harness pull` again in a few minutes.',
370
+ });
371
+ return { exitCode: 0 };
372
+ }
373
+ const result = finalizeRemoteAnalyzeResult({
374
+ context,
375
+ rootDir,
376
+ scanPlan: options.scanPlan,
377
+ runId,
378
+ final: status,
379
+ });
380
+ if (result.exitCode === 0 && isTruthyFlagValue(context.flags.sync)) {
381
+ await approveAndSyncPulledReport(context);
382
+ }
383
+ return result;
384
+ }
385
+ /** Accept every truthy spelling a bare or internal `--sync` flag can arrive as. */
386
+ function isTruthyFlagValue(value) {
387
+ return value === true || value === 1 || value === 'true' || value === '1' || value === 'yes';
388
+ }
389
+ /**
390
+ * Best-effort approve + `harness sync --from-report`, the same follow-up calls
391
+ * `moda init` makes after a successful analyze. A network blip must not fail
392
+ * the pull; the report artifacts are already on disk, and the user gets the
393
+ * manual recovery command instead.
394
+ */
395
+ async function approveAndSyncPulledReport(context) {
396
+ const machine = context.outputMode !== 'human';
397
+ const agentMode = context.outputMode === 'agent' || context.outputMode === 'agent-stream';
398
+ const subFlags = {
399
+ yes: 'true',
400
+ quiet: 'true',
401
+ 'no-telemetry': 'true',
402
+ ...(machine ? { json: 'true' } : {}),
403
+ ...(agentMode ? { agent: 'true' } : {}),
404
+ ...(context.profile ? { profile: context.profile } : {}),
405
+ };
406
+ try {
407
+ await runHarnessCommand(createCommandContext({
408
+ command: 'harness',
409
+ positional: 'approve',
410
+ positionals: ['approve'],
411
+ flags: subFlags,
412
+ }, { cwd: context.cwd, env: context.env }));
413
+ await runHarnessCommand(createCommandContext({
414
+ command: 'harness',
415
+ positional: 'sync',
416
+ positionals: ['sync'],
417
+ flags: { ...subFlags, 'from-report': 'true' },
418
+ }, { cwd: context.cwd, env: context.env }));
419
+ writeHumanProgress(context, 'Moda Analyst (remote): harness graph synced to Moda.');
420
+ }
421
+ catch (error) {
422
+ const message = error instanceof Error ? error.message : String(error);
423
+ context.output.writeEvent({
424
+ event: 'warning',
425
+ phase: 'remote_sync',
426
+ message: `Harness sync skipped: ${message}`,
427
+ elapsed_ms: Date.now() - context.startedAt,
428
+ });
429
+ writeHumanProgress(context, `Harness sync skipped: ${message}. Run \`moda harness approve --yes && moda harness sync --from-report\` later to upload.`);
430
+ }
431
+ }
173
432
  /** Build the terminal error for a remote run that cannot be recovered client-side. */
174
433
  function remoteAnalyzeHardFailure(final, runId) {
175
434
  const detail = final.status === 'failed'
@@ -183,7 +442,7 @@ function remoteAnalyzeHardFailure(final, runId) {
183
442
  }
184
443
  /**
185
444
  * True when a `failed` hosted run failed specifically because its own report
186
- * validation failed — the only server failure the client can recover. The hosted
445
+ * validation failed, the only server failure the client can recover. The hosted
187
446
  * service attaches a structured validation verdict (`{ status: 'fail', ... }`)
188
447
  * only when it produced a report that failed the report-contract check
189
448
  * (`apps/moda-harness-analyze` server.ts); an operational failure (crashed agent)
@@ -197,6 +456,38 @@ function serverFailedReportValidation(final) {
197
456
  return typeof validation === 'object' && validation !== null && !Array.isArray(validation)
198
457
  && validation.status === 'fail';
199
458
  }
459
+ /**
460
+ * Strip ANSI/OSC escapes and bare control characters from a server-controlled
461
+ * progress string before it can reach a terminal. The hosted-analyze `phase`/
462
+ * `message` originate from LLM-agent tool inputs (file paths, commands) that a
463
+ * hostile repo or prompt injection can lace with escape sequences; the container
464
+ * relays them unsanitized. `moda init` scrubs them via the progress board's
465
+ * `oneLine`, but the standalone `moda harness pull` human path prints them
466
+ * directly — so sanitize once, here, where every consumer flows through. Mirrors
467
+ * `oneLine`'s ANSI + control-char stripping (without the display truncation).
468
+ */
469
+ function sanitizeProgressText(text) {
470
+ return stripAnsi(text)
471
+ .replace(/[\u0000-\u001f\u007f]+/g, ' ')
472
+ .replace(/\s+/g, ' ')
473
+ .trim();
474
+ }
475
+ /** Keep only the documented progress fields; drop anything else a server sends. */
476
+ function normalizeRemoteProgress(value) {
477
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
478
+ return undefined;
479
+ const raw = value;
480
+ const progress = {};
481
+ if (typeof raw.phase === 'string')
482
+ progress.phase = sanitizeProgressText(raw.phase);
483
+ if (typeof raw.message === 'string')
484
+ progress.message = sanitizeProgressText(raw.message);
485
+ if (typeof raw.tool_calls === 'number')
486
+ progress.tool_calls = raw.tool_calls;
487
+ if (typeof raw.updated_ms === 'number')
488
+ progress.updated_ms = raw.updated_ms;
489
+ return Object.keys(progress).length > 0 ? progress : undefined;
490
+ }
200
491
  /** Map CLI flags to the optional server-side analyze options in the wire contract. */
201
492
  function buildRemoteAnalyzeOptions(flags) {
202
493
  const options = {};
@@ -220,35 +511,6 @@ async function postRemoteAnalyze(ingestUrl, apiKey, body) {
220
511
  }
221
512
  return parsed;
222
513
  }
223
- async function pollRemoteAnalyze(options) {
224
- const { ingestUrl, apiKey, runId, context } = options;
225
- const timeoutMs = numericEnv('MODA_HARNESS_REMOTE_TIMEOUT_MS', DEFAULT_REMOTE_ANALYZE_TIMEOUT_MS);
226
- const initialMs = numericEnvAllowZero('MODA_HARNESS_REMOTE_POLL_INITIAL_MS', DEFAULT_REMOTE_POLL_INITIAL_MS);
227
- const maxMs = numericEnvAllowZero('MODA_HARNESS_REMOTE_POLL_MAX_MS', DEFAULT_REMOTE_POLL_MAX_MS);
228
- const deadline = Date.now() + timeoutMs;
229
- const url = `${ingestUrl}/v1/harness/analyze/${encodeURIComponent(runId)}`;
230
- // Floor the inter-poll delay so a 0 (or unset) interval can't turn a long
231
- // hosted run into a tight loop hammering the status endpoint.
232
- const capMs = Math.max(maxMs, MIN_REMOTE_POLL_DELAY_MS);
233
- let delay = Math.min(Math.max(initialMs, MIN_REMOTE_POLL_DELAY_MS), capMs);
234
- let last = { success: false, status: 'accepted' };
235
- while (true) {
236
- last = await remoteAnalyzeFetch(url, apiKey, { method: 'GET' }, REMOTE_STATUS_TIMEOUT_MS);
237
- if (last?.status === 'completed' || last?.status === 'failed')
238
- return last;
239
- const remaining = deadline - Date.now();
240
- if (remaining <= 0)
241
- return last ?? { success: false, status: 'running' };
242
- context.output.writeEvent({
243
- event: 'progress',
244
- phase: 'remote_wait',
245
- message: `Server-side analysis status: ${last?.status ?? 'running'}.`,
246
- elapsed_ms: Date.now() - context.startedAt,
247
- });
248
- await sleep(Math.min(delay, remaining));
249
- delay = Math.min(delay * 2, capMs);
250
- }
251
- }
252
514
  async function remoteAnalyzeFetch(url, apiKey, init, timeoutMs) {
253
515
  const controller = new AbortController();
254
516
  const timeout = setTimeout(() => controller.abort(), timeoutMs);