@neta-art/cohub-cli 8.1.0 → 8.1.1

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.
@@ -1,6 +1,6 @@
1
1
  import { resolveExecutionAppId, } from "@neta-art/cohub";
2
2
  import { createClient } from "../client.js";
3
- import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
3
+ import { error, formatLocalDateTime, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
4
4
  function collectOption(value, previous = []) {
5
5
  return [...previous, value];
6
6
  }
@@ -69,7 +69,7 @@ function printOrder(order) {
69
69
  { key: "status", label: "Status" },
70
70
  { key: "amount", label: "Amount" },
71
71
  { key: "paid", label: "Paid" },
72
- { key: "created", label: "Created" },
72
+ { key: "created", label: "Created", format: formatLocalDateTime },
73
73
  { key: "paidAt", label: "Paid At" },
74
74
  ]);
75
75
  }
@@ -1,5 +1,5 @@
1
1
  import { createClient } from "../client.js";
2
- import { handleHttp, json as outJson, jsonRequested, table } from "../output.js";
2
+ import { formatLocalDateTime, handleHttp, json as outJson, jsonRequested, table } from "../output.js";
3
3
  function parseInteger(value, name, min) {
4
4
  const parsed = Number.parseInt(value, 10);
5
5
  if (!Number.isSafeInteger(parsed) || parsed < min) {
@@ -70,7 +70,7 @@ export function registerMe(program) {
70
70
  });
71
71
  if (jsonRequested(opts))
72
72
  return outJson(activity);
73
- console.log(`\n ${activity.range.from} → ${activity.range.to}`);
73
+ console.log(`\n ${formatLocalDateTime(activity.range.from)} → ${formatLocalDateTime(activity.range.to)}`);
74
74
  table([activity.summary], [
75
75
  { key: "totalTokens", label: "Tokens" },
76
76
  { key: "costTotal", label: "Cost ($)" },
@@ -1,6 +1,6 @@
1
1
  import { isUuid, } from "@neta-art/cohub";
2
2
  import { createClient } from "../client.js";
3
- import { table, json as outJson, jsonRequested, error, handleHttp } from "../output.js";
3
+ import { formatLocalDateTime, table, json as outJson, jsonRequested, error, handleHttp } from "../output.js";
4
4
  const RESOURCE_TYPES = new Set([
5
5
  "turn",
6
6
  "session",
@@ -104,7 +104,7 @@ Examples:
104
104
  source: `${r.sourceType}:${r.sourceId.slice(0, 8)}`,
105
105
  target: formatTarget(r.targetType, r.targetId),
106
106
  count: r.count,
107
- lastSeen: r.updatedAt.slice(0, 10),
107
+ lastSeen: formatLocalDateTime(r.updatedAt).slice(0, 10),
108
108
  }));
109
109
  table(rows, [
110
110
  { key: "kind", label: "Kind" },
@@ -1,3 +1,15 @@
1
1
  import type { Command } from "commander";
2
+ import { type NativeIpcResponse } from "../runtime/native-ipc.js";
2
3
  export { resolveLocalSpaceName, parseRuntimeHarnesses } from "../runtime/launch.js";
4
+ export declare function applyNativeImportResponse(result: {
5
+ imported: number;
6
+ failed: Array<{
7
+ path: string;
8
+ message: string;
9
+ }>;
10
+ skipped: Array<{
11
+ path: string;
12
+ message: string;
13
+ }>;
14
+ }, path: string, response: NativeIpcResponse): void;
3
15
  export declare function registerRuntime(program: Command): void;
@@ -17,6 +17,16 @@ const reportFailure = (cause) => {
17
17
  process.stderr.write(`Runtime failed: ${serializeDiagnosticError(cause).message}\n`);
18
18
  process.exitCode = 1;
19
19
  };
20
+ export function applyNativeImportResponse(result, path, response) {
21
+ if (!response.ok) {
22
+ if (response.skipped) {
23
+ result.skipped.push({ path, message: response.message });
24
+ return;
25
+ }
26
+ throw new Error(response.message);
27
+ }
28
+ result.imported += 1;
29
+ }
20
30
  export function registerRuntime(program) {
21
31
  const runtime = program.command("runtime").description("Connect a local workspace");
22
32
  runtime.command("up [dir]")
@@ -107,7 +117,7 @@ export function registerRuntime(program) {
107
117
  const candidates = discovered.candidates.filter((candidate) => !options.session || candidate.nativeSessionId === options.session);
108
118
  const result = {
109
119
  spaceId, root, harnesses, candidates, errors: discovered.errors,
110
- imported: 0, failed: [], dryRun: Boolean(options.dryRun),
120
+ imported: 0, failed: [], skipped: [], dryRun: Boolean(options.dryRun),
111
121
  complete: discovered.errors.length === 0,
112
122
  };
113
123
  if (jsonRequested(options) && options.dryRun) {
@@ -163,9 +173,7 @@ export function registerRuntime(program) {
163
173
  try {
164
174
  const response = await requestNativeDaemon({ harness: candidate.harness, cwd: root, path: candidate.path, nativeSessionId: candidate.nativeSessionId,
165
175
  sessionStartedAt: candidate.sessionStartedAt, origin: "local_import", settled: true });
166
- if (!response.ok)
167
- throw new Error(response.message);
168
- result.imported += 1;
176
+ applyNativeImportResponse(result, candidate.path, response);
169
177
  }
170
178
  catch (error) {
171
179
  result.failed.push({ path: candidate.path, message: error instanceof Error ? error.message : String(error) });
@@ -176,7 +184,7 @@ export function registerRuntime(program) {
176
184
  outJson(result);
177
185
  else {
178
186
  process.stdout.write(`Imported ${result.imported}/${candidates.length} native conversation${candidates.length === 1 ? "" : "s"}\n`);
179
- for (const error of [...discovered.errors, ...result.failed])
187
+ for (const error of [...discovered.errors, ...result.skipped, ...result.failed])
180
188
  process.stdout.write(`Skipped ${error.path}: ${error.message}\n`);
181
189
  process.stdout.write("Uploads continue in the local Runtime background\n");
182
190
  }
@@ -1,6 +1,6 @@
1
1
  import { isUuid } from "@neta-art/cohub";
2
2
  import { createClient } from "../client.js";
3
- import { table, json as outJson, jsonRequested, error, handleHttp } from "../output.js";
3
+ import { formatLocalDateTime, table, json as outJson, jsonRequested, error, handleHttp } from "../output.js";
4
4
  const DEFAULT_LIMIT = 20;
5
5
  const MAX_TITLE_LENGTH = 72;
6
6
  const MAX_CONTEXT_LENGTH = 42;
@@ -50,7 +50,7 @@ function rowsFor(items) {
50
50
  title: truncate(item.title, MAX_TITLE_LENGTH),
51
51
  context: truncate(contextFor(item), MAX_CONTEXT_LENGTH),
52
52
  match: item.matchedField,
53
- updated: item.updatedAt ? item.updatedAt.slice(0, 10) : "",
53
+ updated: item.updatedAt ? formatLocalDateTime(item.updatedAt).slice(0, 10) : "",
54
54
  href: item.href,
55
55
  }));
56
56
  }
@@ -1,5 +1,5 @@
1
1
  import { createClient } from "../client.js";
2
- import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
2
+ import { error, formatLocalDateTime, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
3
3
  import { resolveSpace } from "../space.js";
4
4
  const PRODUCT_STATUSES = ["draft", "active"];
5
5
  const PRODUCT_UPDATE_STATUSES = ["draft", "active", "archived"];
@@ -135,7 +135,7 @@ function printOrders(orders) {
135
135
  { key: "product", label: "Product" },
136
136
  { key: "status", label: "Status" },
137
137
  { key: "amount", label: "Amount" },
138
- { key: "created", label: "Created" },
138
+ { key: "created", label: "Created", format: formatLocalDateTime },
139
139
  ]);
140
140
  }
141
141
  async function confirmDanger(opts, detail) {
package/dist/output.d.ts CHANGED
@@ -10,11 +10,18 @@ export type Column = {
10
10
  format?: (value: unknown, row: Row) => string;
11
11
  };
12
12
  /**
13
- * Epoch milliseconds → ISO 8601, matching the ISO timestamps other tables print.
13
+ * Timestamp → local ISO-style date and time with the system's UTC offset.
14
+ *
15
+ * CLI tables are for people and follow the machine's local timezone. Structured
16
+ * output remains untouched so scripts continue to receive the server value.
17
+ */
18
+ export declare function formatLocalDateTime(value: unknown): string;
19
+ /**
20
+ * Epoch milliseconds → local date and time.
14
21
  *
15
22
  * Absent values render empty rather than falling through `Number()` coercion:
16
23
  * `null` and `""` both become 0 there, which would print a confident-looking
17
- * `1970-01-01T00:00:00.000Z` for a timestamp the server never sent.
24
+ * Unix epoch for a timestamp the server never sent.
18
25
  */
19
26
  export declare function formatEpochMs(value: unknown): string;
20
27
  export declare function table(rows: Row[], columns: Column[]): void;
package/dist/output.js CHANGED
@@ -1,11 +1,34 @@
1
1
  import process from "node:process";
2
2
  import { extractBillingPayload } from "@neta-art/cohub";
3
3
  /**
4
- * Epoch milliseconds → ISO 8601, matching the ISO timestamps other tables print.
4
+ * Timestamp → local ISO-style date and time with the system's UTC offset.
5
+ *
6
+ * CLI tables are for people and follow the machine's local timezone. Structured
7
+ * output remains untouched so scripts continue to receive the server value.
8
+ */
9
+ export function formatLocalDateTime(value) {
10
+ const date = value instanceof Date
11
+ ? value
12
+ : typeof value === "number"
13
+ ? new Date(value)
14
+ : typeof value === "string" && value.trim() !== ""
15
+ ? new Date(value)
16
+ : null;
17
+ if (!date || Number.isNaN(date.getTime()))
18
+ return "";
19
+ const pad = (part) => String(part).padStart(2, "0");
20
+ const offsetMinutes = -date.getTimezoneOffset();
21
+ const offsetSign = offsetMinutes >= 0 ? "+" : "-";
22
+ const absoluteOffset = Math.abs(offsetMinutes);
23
+ const offset = `${offsetSign}${pad(Math.floor(absoluteOffset / 60))}:${pad(absoluteOffset % 60)}`;
24
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())} ${offset}`;
25
+ }
26
+ /**
27
+ * Epoch milliseconds → local date and time.
5
28
  *
6
29
  * Absent values render empty rather than falling through `Number()` coercion:
7
30
  * `null` and `""` both become 0 there, which would print a confident-looking
8
- * `1970-01-01T00:00:00.000Z` for a timestamp the server never sent.
31
+ * Unix epoch for a timestamp the server never sent.
9
32
  */
10
33
  export function formatEpochMs(value) {
11
34
  const ms = typeof value === "number"
@@ -15,13 +38,14 @@ export function formatEpochMs(value) {
15
38
  : Number.NaN;
16
39
  if (!Number.isFinite(ms))
17
40
  return "";
18
- const date = new Date(ms);
19
- return Number.isNaN(date.getTime()) ? "" : date.toISOString();
41
+ return formatLocalDateTime(ms);
20
42
  }
21
43
  function cellText(column, row) {
22
44
  const raw = row[column.key];
23
45
  if (column.format)
24
46
  return column.format(raw, row);
47
+ if (/(?:At|Timestamp)$/.test(column.key))
48
+ return formatLocalDateTime(raw);
25
49
  const v = raw ?? "";
26
50
  return typeof v === "object" ? JSON.stringify(v) : String(v);
27
51
  }
@@ -9,7 +9,7 @@ export async function runCodexNativeHook(payload) {
9
9
  if (typeof value.transcript_path !== "string" || !value.transcript_path)
10
10
  return;
11
11
  const result = await requestNativeDaemon({ harness: "codex", cwd: value.cwd, path: value.transcript_path, nativeSessionId: value.session_id });
12
- if (!result.ok)
12
+ if (!result.ok && !result.skipped)
13
13
  throw new Error(result.message);
14
14
  }
15
15
  async function main() {
@@ -10,13 +10,22 @@ type NativeIpcRequest = {
10
10
  sessionStartedAt?: string;
11
11
  origin?: "local_import";
12
12
  };
13
- type NativeIpcResponse = {
13
+ export type NativeIpcResponse = {
14
14
  ok: true;
15
15
  pendingTurns: number;
16
16
  } | {
17
17
  ok: false;
18
+ skipped?: true;
18
19
  message: string;
19
20
  };
21
+ /**
22
+ * `store: null` means the request was valid but nothing was applicable yet (an unbound
23
+ * workspace, or a transcript a harness has not written). That is not a sync failure, so
24
+ * clients stay silent instead of warning about it.
25
+ */
26
+ export declare function nativeCaptureResponse(result: {
27
+ store: NativeSyncStore | null;
28
+ }): Promise<NativeIpcResponse>;
20
29
  export declare function nativeDaemonSocketFor(cwd: string): Promise<string | null>;
21
30
  export declare function requestNativeDaemon(input: Omit<NativeIpcRequest, "type">): Promise<NativeIpcResponse>;
22
31
  export declare function serveNativeDaemon(input: {
@@ -6,6 +6,16 @@ import { currentIdentityKey } from "../space.js";
6
6
  import { canonicalRuntimeRoot, getRuntimeSpaceBinding } from "./space-binding.js";
7
7
  const MAX_LINE_BYTES = 4 * 1024 * 1024;
8
8
  const socketPath = (runtimeRoot) => join(runtimeRoot, "native", "daemon.sock");
9
+ /**
10
+ * `store: null` means the request was valid but nothing was applicable yet (an unbound
11
+ * workspace, or a transcript a harness has not written). That is not a sync failure, so
12
+ * clients stay silent instead of warning about it.
13
+ */
14
+ export async function nativeCaptureResponse(result) {
15
+ if (!result.store)
16
+ return { ok: false, skipped: true, message: "No native capture is pending" };
17
+ return { ok: true, pendingTurns: (await result.store.status()).pendingTurns };
18
+ }
9
19
  const parse = (raw) => {
10
20
  const value = JSON.parse(raw);
11
21
  if (value?.type !== "native.capture" || !["pi", "codex"].includes(value.harness)
@@ -20,14 +30,19 @@ export async function nativeDaemonSocketFor(cwd) {
20
30
  const identity = currentIdentityKey();
21
31
  if (!identity)
22
32
  return null;
23
- const root = await canonicalRuntimeRoot(cwd);
33
+ // A vanished workspace is unbound, not an error; the terminal has nothing left to sync.
34
+ const root = await canonicalRuntimeRoot(cwd).catch((error) => { if (error.code === "ENOENT")
35
+ return null; throw error; });
36
+ if (!root)
37
+ return null;
24
38
  const binding = await getRuntimeSpaceBinding(root, identity);
25
39
  return binding ? socketPath(nativeRuntimeRoot(binding.spaceId)) : null;
26
40
  }
27
41
  export async function requestNativeDaemon(input) {
28
42
  const path = await nativeDaemonSocketFor(input.cwd);
43
+ // An unbound workspace is a normal state, not an error the terminal should surface.
29
44
  if (!path)
30
- return { ok: false, message: "Native Runtime is not bound" };
45
+ return { ok: false, skipped: true, message: "Native Runtime is not bound" };
31
46
  return new Promise((resolve, reject) => {
32
47
  const socket = createConnection(path);
33
48
  let buffer = "";
@@ -88,7 +103,7 @@ export async function serveNativeDaemon(input) {
88
103
  void (async () => {
89
104
  const request = parse(buffer.slice(0, newline));
90
105
  const result = await input.handle(request);
91
- respond({ ok: true, pendingTurns: result.store ? (await result.store.status()).pendingTurns : 0 });
106
+ respond(await nativeCaptureResponse(result));
92
107
  })().catch((error) => respond({ ok: false, message: error instanceof Error ? error.message : String(error) }));
93
108
  });
94
109
  });
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { requestNativeDaemon } from "./native-ipc.js";
2
3
  /** Thin native adapter. The Runtime Daemon owns Cohub auth, WS, retries and archives. */
3
4
  export default function cohubNativeExtension(pi) {
@@ -6,7 +7,6 @@ export default function cohubNativeExtension(pi) {
6
7
  let context = null;
7
8
  let captures = Promise.resolve();
8
9
  let lastError = "";
9
- let timer;
10
10
  const report = (error) => {
11
11
  const message = error instanceof Error ? error.message : String(error);
12
12
  if (message !== lastError)
@@ -19,9 +19,16 @@ export default function cohubNativeExtension(pi) {
19
19
  if (!path)
20
20
  return Promise.resolve();
21
21
  captures = captures.then(async () => {
22
+ // Pi persists the transcript lazily; a not-yet-created file is not a sync failure.
23
+ if (!existsSync(path))
24
+ return;
22
25
  const result = await requestNativeDaemon({ harness: "pi", cwd: ctx.cwd, path, nativeSessionId: ctx.sessionManager.getSessionId(), leafId: ctx.sessionManager.getLeafId(), settled });
23
- if (!result.ok)
26
+ // Nothing to sync yet (unbound workspace, or config not installed) is silence, not a warning.
27
+ if (!result.ok) {
28
+ if (result.skipped)
29
+ return;
24
30
  throw new Error(result.message);
31
+ }
25
32
  ctx.ui.setStatus("cohub", "Cohub");
26
33
  lastError = "";
27
34
  }).catch(report);
@@ -29,17 +36,10 @@ export default function cohubNativeExtension(pi) {
29
36
  };
30
37
  pi.on("session_start", async (_event, ctx) => {
31
38
  await capture(ctx, ctx.isIdle());
32
- if (timer)
33
- clearInterval(timer);
34
- timer = setInterval(() => { void capture(ctx, ctx.isIdle()); }, 5000);
35
- timer.unref();
36
39
  });
37
40
  pi.on("message_end", (_event, ctx) => capture(ctx));
38
41
  pi.on("agent_settled", (_event, ctx) => capture(ctx, true));
39
42
  pi.on("session_shutdown", async (_event, ctx) => {
40
- if (timer)
41
- clearInterval(timer);
42
- timer = undefined;
43
43
  await captures;
44
44
  ctx.ui.setStatus("cohub", undefined);
45
45
  context = null;
@@ -47,4 +47,4 @@ export declare function captureNativeSession(input: {
47
47
  }): Promise<NativeSyncStore | null>;
48
48
  /** The existing Runtime supervisor retries receipts even after the original terminal has exited. */
49
49
  export declare function nativeWebSocketTransport(spaceId: string, identity: string, send: (event: NativeRuntimeEvent) => Promise<unknown>): NativeSyncTransport;
50
- export declare function flushNativeSessions(spaceId: string, identity: string, signal: AbortSignal, report: (error: unknown) => void, transportOverride?: NativeSyncTransport): Promise<void>;
50
+ export declare function flushNativeSessions(spaceId: string, identity: string, signal: AbortSignal, report: (error: unknown) => void, transportOverride?: NativeSyncTransport): Promise<boolean>;
@@ -120,6 +120,10 @@ export async function readNativeSyncConfig(runtimeRoot, identity) {
120
120
  }
121
121
  }
122
122
  const nativeStores = new Map();
123
+ const missing = (error) => error.code === "ENOENT";
124
+ /** Canonical alias, but a missing target reports itself instead of failing capture. */
125
+ const canonicalPathOrNull = async (path) => canonicalRuntimeRoot(path).catch((error) => { if (missing(error))
126
+ return null; throw error; });
123
127
  /** Local capture only. Neither Pi callbacks nor Codex hooks wait for Cohub's network. */
124
128
  export async function captureNativeSession(input) {
125
129
  if (process.env.COHUB_TURN_ID || process.env.COHUB_EXECUTION_TOKEN)
@@ -127,7 +131,11 @@ export async function captureNativeSession(input) {
127
131
  const identity = currentIdentityKey();
128
132
  if (!identity)
129
133
  return null;
130
- const root = await canonicalRuntimeRoot(input.cwd);
134
+ // Pi and Codex create their transcripts lazily, so a pending capture for a file that does
135
+ // not exist yet (or a workspace that has since been removed) is not a sync failure.
136
+ const root = await canonicalPathOrNull(input.cwd);
137
+ if (!root)
138
+ return null;
131
139
  const space = await getRuntimeSpaceBinding(root, identity);
132
140
  if (!space)
133
141
  return null;
@@ -135,9 +143,11 @@ export async function captureNativeSession(input) {
135
143
  const config = await readNativeSyncConfig(runtimeRoot, identity);
136
144
  if (!config || config.root !== root || config.spaceId !== space.spaceId || !config.harnesses.includes(input.harness))
137
145
  return null;
138
- const path = await canonicalRuntimeRoot(input.path);
146
+ const path = await canonicalPathOrNull(input.path);
147
+ if (!path)
148
+ return null;
139
149
  const transcript = await readNativeTranscript(path, input.harness, input);
140
- if (await canonicalRuntimeRoot(transcript.cwd) !== root || input.nativeSessionId && transcript.nativeSessionId !== input.nativeSessionId)
150
+ if (await canonicalPathOrNull(transcript.cwd) !== root || input.nativeSessionId && transcript.nativeSessionId !== input.nativeSessionId)
141
151
  throw new Error("Native transcript belongs to another project or Session");
142
152
  const key = JSON.stringify([identity, space.spaceId, input.harness, transcript.nativeSessionId, path]);
143
153
  let store = nativeStores.get(key);
@@ -152,8 +162,7 @@ export async function captureNativeSession(input) {
152
162
  }
153
163
  if (!store) {
154
164
  const managed = await findRuntimeNativeSession(runtimeRoot, input.harness, transcript.nativeSessionId, path);
155
- const managedPath = managed ? await canonicalRuntimeRoot(managed.path).catch((error) => { if (error.code === "ENOENT")
156
- return null; throw error; }) : null;
165
+ const managedPath = managed ? await canonicalPathOrNull(managed.path) : null;
157
166
  if (candidates.length && managedPath !== path)
158
167
  throw new Error("Native path changed; original bindings retained");
159
168
  // Restored Pi working copies can share a native ID. Existing Cohub sidecars disambiguate them.
@@ -180,30 +189,33 @@ export function nativeWebSocketTransport(spaceId, identity, send) {
180
189
  }
181
190
  export async function flushNativeSessions(spaceId, identity, signal, report, transportOverride) {
182
191
  if (currentIdentityKey() !== identity)
183
- return;
192
+ return false;
184
193
  const runtimeRoot = nativeRuntimeRoot(spaceId);
185
194
  const config = await readNativeSyncConfig(runtimeRoot, identity);
186
195
  if (!config || config.spaceId !== spaceId)
187
- return;
196
+ return false;
188
197
  const transport = transportOverride;
189
198
  if (!transport)
190
- return;
199
+ return false;
191
200
  const stores = await listNativeSyncStores(runtimeRoot, spaceId, identity, transport);
201
+ let failed = false;
192
202
  for (const store of stores) {
193
203
  signal.throwIfAborted();
194
204
  const binding = await store.binding();
195
205
  if (!config.harnesses.includes(binding.harness))
196
206
  continue;
197
207
  try {
198
- // Codex hooks only push capture requests while its terminal lives; the Daemon keeps re-reading
199
- // the transcript between hooks so Stop-flushed Turns are picked up even if a hook is missed.
208
+ // Codex hooks trigger captures; re-reading here also closes gaps after reconnects or missed hooks.
200
209
  if (binding.harness === "codex")
201
210
  await store.capture(binding.path, await readNativeTranscript(binding.path, "codex"));
202
211
  await store.flush(AbortSignal.any([signal, AbortSignal.timeout(30_000)]));
203
212
  }
204
213
  catch (error) {
205
- if (!signal.aborted)
214
+ if (!signal.aborted) {
215
+ failed = true;
206
216
  report(error);
217
+ }
207
218
  }
208
219
  }
220
+ return failed;
209
221
  }
@@ -220,29 +220,81 @@ export async function runRuntime(config, onState, externalSignal, onDiagnostic)
220
220
  }
221
221
  };
222
222
  bridgeTask = runBridge();
223
- closeNativeDaemon = await serveNativeDaemon({ runtimeRoot: store.root, handle: async (request) => ({
224
- store: await captureNativeSession(request),
225
- }) });
223
+ // Native reconciliation is event-driven: captures and channel reconnects wake it, while failures
224
+ // get bounded exponential retries so idle Runtimes do not scan every few seconds.
225
+ let nativeWakePending = true;
226
+ let nativeWake = null;
227
+ let nativeRetryTimer;
228
+ let nativeRetryDelay = 1000;
229
+ const wakeNativeSync = () => {
230
+ nativeWakePending = true;
231
+ if (nativeRetryTimer) {
232
+ clearTimeout(nativeRetryTimer);
233
+ nativeRetryTimer = undefined;
234
+ }
235
+ nativeWake?.();
236
+ nativeWake = null;
237
+ };
238
+ const waitForNativeWake = async () => {
239
+ if (nativeWakePending) {
240
+ nativeWakePending = false;
241
+ return;
242
+ }
243
+ await new Promise((resolve) => {
244
+ const finish = () => {
245
+ signal.removeEventListener("abort", finish);
246
+ if (nativeWake === finish)
247
+ nativeWake = null;
248
+ resolve();
249
+ };
250
+ nativeWake = finish;
251
+ signal.addEventListener("abort", finish, { once: true });
252
+ if (signal.aborted)
253
+ finish();
254
+ });
255
+ nativeWakePending = false;
256
+ };
226
257
  nativeSyncTask = (async () => {
227
258
  const report = (error) => diagnostics.log("warn", "native.sync_pending", { error: serializeDiagnosticError(error) });
228
259
  while (!signal.aborted) {
260
+ await waitForNativeWake();
261
+ if (signal.aborted)
262
+ break;
263
+ if (!nativeSend)
264
+ continue;
265
+ let failed = false;
229
266
  try {
230
- if (nativeSend)
231
- await flushNativeSessions(config.spaceId, config.identity, signal, report, nativeWebSocketTransport(config.spaceId, config.identity, nativeSend));
267
+ failed = await flushNativeSessions(config.spaceId, config.identity, signal, report, nativeWebSocketTransport(config.spaceId, config.identity, nativeSend));
232
268
  }
233
269
  catch (error) {
234
- if (!signal.aborted)
270
+ if (!signal.aborted) {
235
271
  report(error);
272
+ failed = true;
273
+ }
274
+ }
275
+ if (failed && !signal.aborted) {
276
+ nativeRetryTimer = setTimeout(() => { nativeRetryTimer = undefined; wakeNativeSync(); }, nativeRetryDelay);
277
+ nativeRetryDelay = Math.min(30_000, nativeRetryDelay * 2);
278
+ }
279
+ else if (!failed) {
280
+ nativeRetryDelay = 1000;
236
281
  }
237
- await delay(5000, undefined, { signal }).catch(() => undefined);
238
282
  }
283
+ if (nativeRetryTimer)
284
+ clearTimeout(nativeRetryTimer);
239
285
  })();
286
+ closeNativeDaemon = await serveNativeDaemon({ runtimeRoot: store.root, handle: async (request) => {
287
+ const result = { store: await captureNativeSession(request) };
288
+ if (result.store)
289
+ wakeNativeSync();
290
+ return result;
291
+ } });
240
292
  await serveRuntime({
241
293
  spaceId: config.spaceId, cwd: config.root, url: url.toString(), capabilities,
242
294
  harnesses: config.executables, runtimeId: status.runtimeId, diagnostics, token, signal, store,
243
295
  onReady: () => update({ harnessConnected: true }),
244
296
  onDisconnected: () => { nativeSend = null; update({ harnessConnected: false }); },
245
- onNativeChannel: (send) => { nativeSend = send; },
297
+ onNativeChannel: (send) => { nativeSend = send; wakeNativeSync(); },
246
298
  });
247
299
  }
248
300
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neta-art/cohub-cli",
3
- "version": "8.1.0",
3
+ "version": "8.1.1",
4
4
  "description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
5
5
  "type": "module",
6
6
  "engines": {