nolo-cli 0.1.19 → 0.1.20

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 (111) hide show
  1. package/README.md +9 -1
  2. package/agent-runtime/agentConfigOptions.ts +12 -0
  3. package/agent-runtime/agentRecordConfig.ts +99 -0
  4. package/agent-runtime/agentRecordKeys.ts +14 -0
  5. package/agent-runtime/dialogMessageRecord.ts +16 -0
  6. package/agent-runtime/dialogWritePlan.ts +130 -0
  7. package/agent-runtime/hostAdapter.ts +13 -0
  8. package/agent-runtime/hybridRecordStore.ts +147 -0
  9. package/agent-runtime/index.ts +69 -0
  10. package/agent-runtime/localLoop.ts +69 -5
  11. package/agent-runtime/localToolPolicy.ts +130 -0
  12. package/agent-runtime/localWorkspaceTools.ts +1532 -0
  13. package/agent-runtime/openAiCompatibleProvider.ts +70 -0
  14. package/agent-runtime/openAiCompatibleProviderConfig.ts +38 -0
  15. package/agent-runtime/platformChatProvider.ts +241 -0
  16. package/agent-runtime/taskWorkspace.ts +193 -0
  17. package/agent-runtime/types.ts +1 -0
  18. package/agent-runtime/workspaceSession.ts +76 -0
  19. package/agentAliases.ts +37 -0
  20. package/agentPullCommand.ts +1 -1
  21. package/agentRunCommand.ts +278 -52
  22. package/agentRuntimeCommands.ts +354 -164
  23. package/agentRuntimeLocal.ts +38 -0
  24. package/ai/agent/agentSlice.ts +10 -0
  25. package/ai/agent/buildEditingContext.ts +5 -0
  26. package/ai/agent/buildSystemPrompt.ts +41 -18
  27. package/ai/agent/canvasEditingContext.ts +49 -0
  28. package/ai/agent/cliExecutor.ts +15 -4
  29. package/ai/agent/createAgentSchema.ts +2 -0
  30. package/ai/agent/executeToolCall.ts +3 -2
  31. package/ai/agent/hooks/usePublicAgents.ts +6 -0
  32. package/ai/agent/pageBuilderHandoffRules.ts +75 -0
  33. package/ai/agent/runAgentClientLoop.ts +4 -1
  34. package/ai/agent/runtimeGuidance.ts +19 -0
  35. package/ai/agent/server/fetchPublicAgents.ts +51 -1
  36. package/ai/agent/streamAgentChatTurn.ts +20 -2
  37. package/ai/agent/streamAgentChatTurnUtils.ts +60 -16
  38. package/ai/chat/accumulateToolCallChunks.ts +40 -9
  39. package/ai/chat/parseApiError.ts +3 -0
  40. package/ai/chat/sendOpenAICompletionsRequest.native.ts +23 -10
  41. package/ai/chat/sendOpenAICompletionsRequest.ts +13 -1
  42. package/ai/chat/updateTotalUsage.ts +26 -9
  43. package/ai/llm/deepinfra.ts +51 -0
  44. package/ai/llm/getPricing.ts +6 -0
  45. package/ai/llm/kimi.ts +2 -0
  46. package/ai/llm/openrouterModels.ts +0 -135
  47. package/ai/llm/providers.ts +1 -0
  48. package/ai/llm/types.ts +8 -0
  49. package/ai/taskRun/taskRunProtocol.ts +823 -0
  50. package/ai/token/calculatePrice.ts +30 -0
  51. package/ai/token/externalToolCost.ts +49 -29
  52. package/ai/token/prepareTokenUsageData.ts +6 -1
  53. package/ai/token/serverTokenWriter.ts +4 -2
  54. package/ai/tools/agent/agentTools.ts +21 -0
  55. package/ai/tools/agent/presets/appBuilderPreset.ts +7 -0
  56. package/ai/tools/agent/streamParallelAgentsTool.ts +2 -1
  57. package/ai/tools/agent/taskRunTool.ts +112 -0
  58. package/ai/tools/applyEditTool.ts +6 -3
  59. package/ai/tools/applyLineEditsTool.ts +6 -3
  60. package/ai/tools/checkEnvTool.ts +14 -9
  61. package/ai/tools/codeSearchTool.ts +17 -5
  62. package/ai/tools/execBashTool.ts +33 -29
  63. package/ai/tools/fetchWebpageSupport.ts +24 -0
  64. package/ai/tools/fetchWebpageTool.ts +18 -5
  65. package/ai/tools/index.ts +158 -0
  66. package/ai/tools/jdProductScraperTool.ts +821 -0
  67. package/ai/tools/listFilesTool.ts +6 -3
  68. package/ai/tools/localFilesTool.ts +200 -0
  69. package/ai/tools/readFileTool.ts +6 -3
  70. package/ai/tools/searchRepoTool.ts +6 -3
  71. package/ai/tools/table/rowTools.ts +6 -1
  72. package/ai/tools/taobaoTmallProductScraperTool.ts +49 -0
  73. package/ai/tools/toolApiClient.ts +20 -6
  74. package/ai/tools/wereadGatewayTool.ts +152 -0
  75. package/ai/tools/writeFileTool.ts +6 -3
  76. package/client/agentConfigResolver.test.ts +70 -0
  77. package/client/agentConfigResolver.ts +1 -0
  78. package/client/agentRun.test.ts +361 -7
  79. package/client/agentRun.ts +449 -63
  80. package/client/hybridRecordStore.test.ts +115 -0
  81. package/client/hybridRecordStore.ts +41 -0
  82. package/client/localAgentRecords.test.ts +27 -0
  83. package/client/localAgentRecords.ts +7 -0
  84. package/client/localDialogRecords.test.ts +124 -0
  85. package/client/localDialogRecords.ts +30 -0
  86. package/client/localProviderResolver.test.ts +78 -0
  87. package/client/localProviderResolver.ts +1 -0
  88. package/client/localRuntimeAdapter.test.ts +621 -9
  89. package/client/localRuntimeAdapter.ts +275 -250
  90. package/client/localRuntimeDryRun.test.ts +116 -0
  91. package/client/localToolPolicy.ts +8 -81
  92. package/client/taskRunPrompt.ts +26 -0
  93. package/client/taskWorktree.ts +8 -0
  94. package/client/workspaceSession.test.ts +57 -0
  95. package/client/workspaceSession.ts +11 -0
  96. package/commandRegistry.ts +23 -6
  97. package/connectorRunArtifact.ts +121 -0
  98. package/database/actions/write.ts +16 -2
  99. package/database/hooks/useUserData.ts +9 -3
  100. package/database/server/dataHandlers.ts +18 -20
  101. package/database/server/emailRepository.ts +3 -3
  102. package/database/server/patch.ts +18 -10
  103. package/database/server/query.ts +43 -4
  104. package/database/server/read.ts +24 -38
  105. package/database/server/recordIdentity.ts +100 -0
  106. package/database/server/write.ts +21 -25
  107. package/index.ts +70 -33
  108. package/machineCommands.ts +318 -144
  109. package/package.json +4 -1
  110. package/tableCommands.ts +181 -0
  111. package/taskRunCommand.ts +237 -0
@@ -1,9 +1,15 @@
1
- import type { MachineHeartbeat } from "./connector-experimental/protocol";
2
- import { detectMachineInfo } from "./connector-experimental/machineInfo";
3
- import { mkdirSync, openSync } from "node:fs";
4
- import { homedir } from "node:os";
5
- import { dirname, join } from "node:path";
6
- import { DEFAULT_NOLO_SERVER_URL } from "./defaultServer";
1
+ import type { MachineHeartbeat } from "./connector-experimental/protocol";
2
+ import { detectMachineInfo } from "./connector-experimental/machineInfo";
3
+ import { spawn } from "node:child_process";
4
+ import { mkdirSync, openSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { dirname, join } from "node:path";
7
+ import { DEFAULT_NOLO_SERVER_URL } from "./defaultServer";
8
+ import {
9
+ collectConnectorRunArtifact,
10
+ readConnectorGitHead,
11
+ resolveConnectorRunCwd,
12
+ } from "./connectorRunArtifact";
7
13
  import {
8
14
  type HeartbeatLoopOptions,
9
15
  runHeartbeatLoop as defaultRunHeartbeatLoop,
@@ -22,11 +28,11 @@ type ConnectorWebSocketOptions = {
22
28
  onMessage: (message: string) => void | Promise<void>;
23
29
  sentMessages: string[];
24
30
  };
25
- type LocalCliExecutor = (
26
- provider: string,
27
- prompt: string,
28
- options: { model?: string; yolo?: boolean }
29
- ) => Promise<{ text: string; raw?: string; elapsed?: number }>;
31
+ type LocalCliExecutor = (
32
+ provider: string,
33
+ prompt: string,
34
+ options: { model?: string; timeout?: number; cwd?: string; yolo?: boolean; env?: EnvLike }
35
+ ) => Promise<{ text: string; raw?: string; elapsed?: number }>;
30
36
 
31
37
  export type MachineSummary = {
32
38
  machineId: string;
@@ -40,12 +46,15 @@ export type MachineSummary = {
40
46
  lastSeenAt: number;
41
47
  };
42
48
 
43
- type MachineCommandDeps = {
44
- env?: EnvLike;
45
- output?: OutputLike;
46
- fetchImpl?: typeof fetch;
47
- machineInfo?: () => MachineHeartbeat;
48
- runHeartbeatLoop?: (options: HeartbeatLoopOptions) => Promise<void>;
49
+ type MachineCommandDeps = {
50
+ env?: EnvLike;
51
+ output?: OutputLike;
52
+ cliEntrypointPath?: string;
53
+ maxConnectorAttempts?: number;
54
+ sleep?: (ms: number) => Promise<void>;
55
+ fetchImpl?: typeof fetch;
56
+ machineInfo?: () => MachineHeartbeat;
57
+ runHeartbeatLoop?: (options: HeartbeatLoopOptions) => Promise<void>;
49
58
  connectWebSocket?: (url: string, options: ConnectorWebSocketOptions) => Promise<void>;
50
59
  executeCli?: LocalCliExecutor;
51
60
  spawnDaemon?: (args: {
@@ -99,13 +108,17 @@ function resolveConnectAuthToken(args: string[], env: EnvLike) {
99
108
  return readOption(args, "--api-key") || readOption(args, "--token") || resolveAuthToken(env);
100
109
  }
101
110
 
102
- function resolveDaemonLogPath(env: EnvLike) {
103
- return env.NOLO_CONNECT_LOG || join(homedir(), ".nolo", "logs", "connector.log");
104
- }
105
-
106
- function defaultSpawnDaemon(args: {
107
- cmd: string[];
108
- cwd: string;
111
+ function resolveDaemonLogPath(env: EnvLike) {
112
+ return env.NOLO_CONNECT_LOG || join(homedir(), ".nolo", "logs", "connector.log");
113
+ }
114
+
115
+ function buildDaemonCommand(cliEntrypointPath: string | undefined) {
116
+ return [process.execPath, cliEntrypointPath || import.meta.path, "connect", "--ws"];
117
+ }
118
+
119
+ function defaultSpawnDaemon(args: {
120
+ cmd: string[];
121
+ cwd: string;
109
122
  env: EnvLike;
110
123
  logPath: string;
111
124
  }) {
@@ -114,24 +127,35 @@ function defaultSpawnDaemon(args: {
114
127
  const env = Object.fromEntries(
115
128
  Object.entries(args.env).filter((entry): entry is [string, string] => typeof entry[1] === "string")
116
129
  );
117
- const proc = Bun.spawn({
118
- cmd: args.cmd,
119
- cwd: args.cwd,
120
- env,
121
- stdin: "ignore",
122
- stdout: out,
123
- stderr: out,
124
- });
125
- proc.unref();
126
- return { pid: proc.pid };
127
- }
128
-
129
- function resolveHeartbeatIntervalMs(env: EnvLike) {
130
- const raw = Number(env.NOLO_CONNECT_HEARTBEAT_MS ?? "");
131
- return Number.isFinite(raw) && raw > 0 ? raw : 30_000;
132
- }
133
-
134
- async function defaultConnectWebSocket(url: string, options: ConnectorWebSocketOptions) {
130
+ const proc = spawn(args.cmd[0], args.cmd.slice(1), {
131
+ cwd: args.cwd,
132
+ env,
133
+ detached: true,
134
+ stdio: ["ignore", out, out],
135
+ });
136
+ proc.unref();
137
+ return { pid: proc.pid };
138
+ }
139
+
140
+ function resolveHeartbeatIntervalMs(env: EnvLike) {
141
+ const raw = Number(env.NOLO_CONNECT_HEARTBEAT_MS ?? "");
142
+ return Number.isFinite(raw) && raw > 0 ? raw : 30_000;
143
+ }
144
+
145
+ const CONNECTOR_WS_KEEPALIVE_MS = 25_000;
146
+
147
+ function buildConnectorKeepaliveMessage() {
148
+ return JSON.stringify({ type: "connector.keepalive", sentAt: Date.now() });
149
+ }
150
+
151
+ const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
152
+
153
+ function resolveConnectorReconnectDelayMs(env: EnvLike) {
154
+ const raw = Number(env.NOLO_CONNECT_RECONNECT_MS ?? "");
155
+ return Number.isFinite(raw) && raw >= 0 ? raw : 5_000;
156
+ }
157
+
158
+ async function defaultConnectWebSocket(url: string, options: ConnectorWebSocketOptions) {
135
159
  const WebSocketCtor = globalThis.WebSocket;
136
160
  if (!WebSocketCtor) {
137
161
  throw new Error("WebSocket is not available in this runtime");
@@ -139,16 +163,34 @@ async function defaultConnectWebSocket(url: string, options: ConnectorWebSocketO
139
163
  await new Promise<void>((resolve, reject) => {
140
164
  const ws = new WebSocketCtor(url, {
141
165
  headers: options.headers,
142
- } as any);
143
- let opened = false;
144
- ws.addEventListener("open", () => {
145
- opened = true;
146
- }, { once: true });
147
- ws.addEventListener("error", () => reject(new Error("connector websocket failed")));
148
- ws.addEventListener("close", () => {
149
- if (opened) resolve();
150
- else reject(new Error("connector websocket closed before opening"));
151
- }, { once: true });
166
+ } as any);
167
+ let opened = false;
168
+ let keepalive: ReturnType<typeof setInterval> | null = null;
169
+ const clearKeepalive = () => {
170
+ if (keepalive) {
171
+ clearInterval(keepalive);
172
+ keepalive = null;
173
+ }
174
+ };
175
+ ws.addEventListener("open", () => {
176
+ opened = true;
177
+ keepalive = setInterval(() => {
178
+ try {
179
+ ws.send(buildConnectorKeepaliveMessage());
180
+ } catch {
181
+ clearKeepalive();
182
+ }
183
+ }, CONNECTOR_WS_KEEPALIVE_MS);
184
+ }, { once: true });
185
+ ws.addEventListener("error", () => {
186
+ clearKeepalive();
187
+ reject(new Error("connector websocket failed"));
188
+ });
189
+ ws.addEventListener("close", () => {
190
+ clearKeepalive();
191
+ if (opened) resolve();
192
+ else reject(new Error("connector websocket closed before opening"));
193
+ }, { once: true });
152
194
  ws.addEventListener("message", (event) => {
153
195
  const startIndex = options.sentMessages.length;
154
196
  Promise.resolve(options.onMessage(String(event.data))).then(() => {
@@ -160,30 +202,96 @@ async function defaultConnectWebSocket(url: string, options: ConnectorWebSocketO
160
202
  });
161
203
  }
162
204
 
163
- async function defaultExecuteCli(provider: string, prompt: string, options: { model?: string; yolo?: boolean }) {
164
- const { executeCli } = await import("./ai/agent/cliExecutor");
165
- return executeCli(provider as any, prompt, options);
166
- }
205
+ async function defaultExecuteCli(
206
+ provider: string,
207
+ prompt: string,
208
+ options: { model?: string; timeout?: number; cwd?: string; yolo?: boolean; env?: EnvLike }
209
+ ) {
210
+ const { executeCli } = await import("./ai/agent/cliExecutor");
211
+ return executeCli(provider as any, prompt, options);
212
+ }
167
213
 
168
214
  function detectLaunchableMachineInfo() {
169
215
  return detectMachineInfo({ probeLaunchable: true });
170
216
  }
171
217
 
172
- function buildConnectorCliPrompt(agentConfig: any, userInput: string) {
173
- const policy = resolveMachineRunPermissionPolicy(agentConfig);
174
- return [
175
- typeof agentConfig?.prompt === "string" ? agentConfig.prompt.trim() : "",
176
- buildMachinePermissionPromptBlock(policy),
177
- `--- User task ---\n${userInput}`,
178
- ].filter(Boolean).join("\n\n");
179
- }
180
-
181
- async function handleConnectorRunMessage(
182
- machine: MachineHeartbeat,
183
- message: string,
184
- send: (message: string) => void,
185
- executeCli: LocalCliExecutor
186
- ) {
218
+ function inferTaskRunRole(agentKey: string, agentConfig: any) {
219
+ const haystack = [
220
+ agentKey,
221
+ agentConfig?.name,
222
+ agentConfig?.prompt,
223
+ ].filter(Boolean).join("\n").toLowerCase();
224
+ if (haystack.includes("frontend") || haystack.includes("前端")) return "frontend";
225
+ if (haystack.includes("fullstack") || haystack.includes("全栈")) return "fullstack";
226
+ if (haystack.includes("review") || haystack.includes("审查")) return "reviewer";
227
+ if (haystack.includes("project-manager") || haystack.includes("项目经理")) return "pm";
228
+ return "codex";
229
+ }
230
+
231
+ function buildTaskRunBridgePrompt(args: {
232
+ agentKey: string;
233
+ agentConfig: any;
234
+ runtimeContext: any;
235
+ }) {
236
+ const taskRun = args.runtimeContext?.taskRun;
237
+ const rowDbKey = typeof taskRun?.rowDbKey === "string" ? taskRun.rowDbKey.trim() : "";
238
+ if (!rowDbKey) return "";
239
+ const workItemId = typeof taskRun?.workItemId === "string" ? taskRun.workItemId.trim() : "";
240
+ const role = inferTaskRunRole(args.agentKey, args.agentConfig);
241
+ return [
242
+ "--- Nolo task-run tool bridge ---",
243
+ "This CLI runtime does not receive server-side function tools directly.",
244
+ "Use the local task-run bridge command for durable task state writes. Do not edit meta.taskRun with table or database commands.",
245
+ "Run commands from the repository root. The runner already provides server URL and auth in the environment.",
246
+ "Release boundary: after review passes, AI/reviewer/Codex may advance alpha for verification. Do not merge, push, or release main/release unless the human owner explicitly authorizes it in the current task context.",
247
+ "Cost boundary: read taskRun.control from context. Prefer low-cost/free-window models for routine work, and reserve Codex for supervision, quality gates, complex unblocking, and main/release decisions.",
248
+ `Read context: bun packages/cli/index.ts task-run read-context --row-dbkey ${JSON.stringify(rowDbKey)}`,
249
+ ...(workItemId
250
+ ? [
251
+ `Claim work item: bun packages/cli/index.ts task-run claim-work-item --row-dbkey ${JSON.stringify(rowDbKey)} --work-item-id ${JSON.stringify(workItemId)} --agent-key ${JSON.stringify(args.agentKey)} --role ${role}`,
252
+ `Set blocker: bun packages/cli/index.ts task-run set-blocker --row-dbkey ${JSON.stringify(rowDbKey)} --work-item-id ${JSON.stringify(workItemId)} --layer tool --code <CODE> --message <MESSAGE>`,
253
+ `Clear resolved blocker: bun packages/cli/index.ts task-run clear-blocker --row-dbkey ${JSON.stringify(rowDbKey)} --work-item-id ${JSON.stringify(workItemId)} --blocker-id <BLOCKER_ID>`,
254
+ `Submit outcome: bun packages/cli/index.ts task-run submit-outcome --row-dbkey ${JSON.stringify(rowDbKey)} --work-item-id ${JSON.stringify(workItemId)} --summary <SUMMARY>`,
255
+ `Request review: bun packages/cli/index.ts task-run request-review --row-dbkey ${JSON.stringify(rowDbKey)} --work-item-id ${JSON.stringify(workItemId)} --reviewer-agent-key <AGENT_KEY> --message <MESSAGE>`,
256
+ `Record review: bun packages/cli/index.ts task-run record-review --row-dbkey ${JSON.stringify(rowDbKey)} --work-item-id ${JSON.stringify(workItemId)} --review-status <passed|needs_changes|blocked> --reviewer-agent-key ${JSON.stringify(args.agentKey)} --findings <FINDINGS>`,
257
+ `Close work item: bun packages/cli/index.ts task-run close-work-item --row-dbkey ${JSON.stringify(rowDbKey)} --work-item-id ${JSON.stringify(workItemId)} --summary <SUMMARY>`,
258
+ ]
259
+ : []),
260
+ "If a bridge command fails, report the exact command and error.",
261
+ ].join("\n");
262
+ }
263
+
264
+ function buildConnectorCliPrompt(agentConfig: any, userInput: string, bridgeArgs?: {
265
+ agentKey: string;
266
+ runtimeContext: any;
267
+ }) {
268
+ const policy = resolveMachineRunPermissionPolicy(agentConfig);
269
+ return [
270
+ typeof agentConfig?.prompt === "string" ? agentConfig.prompt.trim() : "",
271
+ bridgeArgs ? buildTaskRunBridgePrompt({
272
+ agentKey: bridgeArgs.agentKey,
273
+ agentConfig,
274
+ runtimeContext: bridgeArgs.runtimeContext,
275
+ }) : "",
276
+ buildMachinePermissionPromptBlock(policy),
277
+ `--- User task ---\n${userInput}`,
278
+ ].filter(Boolean).join("\n\n");
279
+ }
280
+
281
+ function normalizeConnectorRunTimeoutMs(value: unknown): number | undefined {
282
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
283
+ return undefined;
284
+ }
285
+ return Math.floor(value);
286
+ }
287
+
288
+ async function handleConnectorRunMessage(
289
+ machine: MachineHeartbeat,
290
+ message: string,
291
+ send: (message: string) => void,
292
+ executeCli: LocalCliExecutor,
293
+ runtimeEnv: EnvLike
294
+ ) {
187
295
  let parsed: any;
188
296
  try {
189
297
  parsed = JSON.parse(message);
@@ -196,37 +304,124 @@ async function handleConnectorRunMessage(
196
304
  if (agentConfig.apiSource !== "cli") {
197
305
  throw new Error("Connector can only execute CLI agents. Set the agent apiSource to cli and choose a cliProvider.");
198
306
  }
199
- const provider = String(agentConfig.cliProvider || "copilot");
200
- const policy = resolveMachineRunPermissionPolicy(agentConfig);
201
- const userInput = String(parsed.payload?.userInput ?? "");
202
- assertMachineRunAllowed(userInput, policy);
203
- const result = await executeCli(
204
- provider,
205
- buildConnectorCliPrompt(agentConfig, userInput),
206
- {
207
- model: agentConfig.model || undefined,
208
- yolo: true,
209
- }
210
- );
211
- send(JSON.stringify({
307
+ const provider = String(agentConfig.cliProvider || "copilot");
308
+ const policy = resolveMachineRunPermissionPolicy(agentConfig);
309
+ const userInput = String(parsed.payload?.userInput ?? "");
310
+ const timeout = normalizeConnectorRunTimeoutMs(parsed.payload?.timeoutMs);
311
+ assertMachineRunAllowed(userInput, policy);
312
+ const cwd = resolveConnectorRunCwd({ env: runtimeEnv, policy });
313
+ const baseSha = await readConnectorGitHead(cwd);
314
+ const result = await executeCli(
315
+ provider,
316
+ buildConnectorCliPrompt(agentConfig, userInput, {
317
+ agentKey: String(parsed.payload?.agentKey ?? ""),
318
+ runtimeContext: parsed.payload?.runtimeContext,
319
+ }),
320
+ {
321
+ model: agentConfig.model || undefined,
322
+ timeout,
323
+ cwd,
324
+ yolo: true,
325
+ env: {
326
+ NOLO_SERVER: runtimeEnv.NOLO_SERVER || runtimeEnv.NOLO_SERVER_URL || runtimeEnv.BASE_URL,
327
+ NOLO_SERVER_URL: runtimeEnv.NOLO_SERVER_URL || runtimeEnv.NOLO_SERVER || runtimeEnv.BASE_URL,
328
+ BASE_URL: runtimeEnv.BASE_URL || runtimeEnv.NOLO_SERVER || runtimeEnv.NOLO_SERVER_URL,
329
+ AUTH_TOKEN: runtimeEnv.AUTH_TOKEN || runtimeEnv.AUTH || runtimeEnv.NOLO_MACHINE_API_KEY,
330
+ NOLO_MACHINE_API_KEY: runtimeEnv.NOLO_MACHINE_API_KEY || runtimeEnv.AUTH_TOKEN || runtimeEnv.AUTH,
331
+ },
332
+ }
333
+ );
334
+ const artifacts = await collectConnectorRunArtifact({
335
+ cwd,
336
+ baseSha,
337
+ exitStatus: "completed",
338
+ });
339
+ send(JSON.stringify({
212
340
  type: "agent.run.result",
213
341
  requestId: parsed.requestId,
214
342
  result: {
215
- content: result.text,
216
- model: agentConfig.model ?? provider,
217
- trace: [{ role: "assistant", content: result.text }],
218
- },
219
- }));
343
+ content: result.text,
344
+ model: agentConfig.model ?? provider,
345
+ trace: [{ role: "assistant", content: result.text }],
346
+ artifacts,
347
+ },
348
+ }));
220
349
  } catch (error) {
221
350
  send(JSON.stringify({
222
351
  type: "agent.run.result",
223
352
  requestId: parsed.requestId,
224
353
  error: error instanceof Error ? error.message : String(error),
225
354
  }));
226
- }
227
- }
228
-
229
- export function formatMachineStatus(machines: MachineSummary[]) {
355
+ }
356
+ }
357
+
358
+ async function runConnectorWebSocketSession(options: {
359
+ env: EnvLike;
360
+ output: OutputLike;
361
+ machine: MachineHeartbeat;
362
+ sendHeartbeat: () => Promise<void>;
363
+ runHeartbeatLoop: (options: HeartbeatLoopOptions) => Promise<void>;
364
+ connectWebSocket: (url: string, options: ConnectorWebSocketOptions) => Promise<void>;
365
+ executeCli: LocalCliExecutor;
366
+ fetchImpl: typeof fetch;
367
+ serverUrl: string;
368
+ authToken: string;
369
+ }) {
370
+ const sentMessages: string[] = [];
371
+ const heartbeatAbort = new AbortController();
372
+ try {
373
+ await options.sendHeartbeat();
374
+ options.output.write(
375
+ `Connector websocket connected: ${options.machine.name} (${options.machine.machineId})\n`
376
+ );
377
+ const heartbeatLoopPromise = options.runHeartbeatLoop({
378
+ intervalMs: resolveHeartbeatIntervalMs(options.env),
379
+ sendHeartbeat: options.sendHeartbeat,
380
+ signal: heartbeatAbort.signal,
381
+ });
382
+ const wsTarget = await resolveConnectorWebSocketTarget({
383
+ serverUrl: options.serverUrl,
384
+ machineId: options.machine.machineId,
385
+ headers: { Authorization: `Bearer ${options.authToken}` },
386
+ fetchImpl: options.fetchImpl,
387
+ });
388
+ const websocketPromise = options.connectWebSocket(
389
+ wsTarget,
390
+ {
391
+ headers: { Authorization: `Bearer ${options.authToken}` },
392
+ sentMessages,
393
+ onMessage: (message) => handleConnectorRunMessage(
394
+ options.machine,
395
+ message,
396
+ (response) => sentMessages.push(response),
397
+ options.executeCli,
398
+ {
399
+ ...options.env,
400
+ NOLO_SERVER: options.serverUrl,
401
+ NOLO_SERVER_URL: options.serverUrl,
402
+ BASE_URL: options.serverUrl,
403
+ AUTH_TOKEN: options.authToken,
404
+ NOLO_MACHINE_API_KEY: options.authToken,
405
+ }
406
+ ),
407
+ }
408
+ );
409
+ await Promise.race([websocketPromise, heartbeatLoopPromise]);
410
+ heartbeatAbort.abort();
411
+ await Promise.allSettled([websocketPromise, heartbeatLoopPromise]);
412
+ return 0;
413
+ } catch (error) {
414
+ heartbeatAbort.abort();
415
+ options.output.write(
416
+ `[nolo] Connector websocket failed: ${
417
+ error instanceof Error ? error.message : String(error)
418
+ }\n`
419
+ );
420
+ return 1;
421
+ }
422
+ }
423
+
424
+ export function formatMachineStatus(machines: MachineSummary[]) {
230
425
  if (machines.length === 0) {
231
426
  return "No connected machines.\nRun `nolo connect` on this computer to register it once.\n";
232
427
  }
@@ -273,62 +468,41 @@ export async function runMachineConnectCommand(
273
468
  }
274
469
  };
275
470
 
276
- if (hasFlag(args, "--daemon") || hasFlag(args, "--background")) {
277
- const logPath = resolveDaemonLogPath(env);
278
- const result = (deps.spawnDaemon ?? defaultSpawnDaemon)({
279
- cmd: [process.execPath, import.meta.path, "connect", "--ws"],
280
- cwd: process.cwd(),
281
- env,
282
- logPath,
471
+ if (hasFlag(args, "--daemon") || hasFlag(args, "--background")) {
472
+ const logPath = resolveDaemonLogPath(env);
473
+ const result = (deps.spawnDaemon ?? defaultSpawnDaemon)({
474
+ cmd: buildDaemonCommand(deps.cliEntrypointPath),
475
+ cwd: process.cwd(),
476
+ env,
477
+ logPath,
283
478
  });
284
479
  output.write(`Connector daemon started${result.pid ? ` pid=${result.pid}` : ""}. Log: ${logPath}\n`);
285
480
  return 0;
286
481
  }
287
482
 
288
- if (hasFlag(args, "--ws")) {
289
- const sentMessages: string[] = [];
290
- const heartbeatAbort = new AbortController();
291
- try {
292
- await sendHeartbeat();
293
- output.write(`Connector websocket connected: ${machine.name} (${machine.machineId})\n`);
294
- const heartbeatLoopPromise = (deps.runHeartbeatLoop ?? defaultRunHeartbeatLoop)({
295
- intervalMs: resolveHeartbeatIntervalMs(env),
296
- sendHeartbeat,
297
- signal: heartbeatAbort.signal,
298
- });
299
- const wsTarget = await resolveConnectorWebSocketTarget({
300
- serverUrl,
301
- machineId: machine.machineId,
302
- headers: { Authorization: `Bearer ${authToken}` },
303
- fetchImpl,
304
- });
305
- const websocketPromise = (deps.connectWebSocket ?? defaultConnectWebSocket)(
306
- wsTarget,
307
- {
308
- headers: { Authorization: `Bearer ${authToken}` },
309
- sentMessages,
310
- onMessage: (message) => handleConnectorRunMessage(
311
- machine,
312
- message,
313
- (response) => sentMessages.push(response),
314
- deps.executeCli ?? defaultExecuteCli
315
- ),
316
- }
317
- );
318
- await Promise.race([websocketPromise, heartbeatLoopPromise]);
319
- heartbeatAbort.abort();
320
- await Promise.allSettled([websocketPromise, heartbeatLoopPromise]);
321
- return 0;
322
- } catch (error) {
323
- heartbeatAbort.abort();
324
- output.write(
325
- `[nolo] Connector websocket failed: ${
326
- error instanceof Error ? error.message : String(error)
327
- }\n`
328
- );
329
- return 1;
330
- }
331
- }
483
+ if (hasFlag(args, "--ws")) {
484
+ const maxAttempts = deps.maxConnectorAttempts ?? Infinity;
485
+ const sleep = deps.sleep ?? defaultSleep;
486
+ const reconnectDelayMs = resolveConnectorReconnectDelayMs(env);
487
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
488
+ const exitCode = await runConnectorWebSocketSession({
489
+ env,
490
+ output,
491
+ machine,
492
+ sendHeartbeat,
493
+ runHeartbeatLoop: deps.runHeartbeatLoop ?? defaultRunHeartbeatLoop,
494
+ connectWebSocket: deps.connectWebSocket ?? defaultConnectWebSocket,
495
+ executeCli: deps.executeCli ?? defaultExecuteCli,
496
+ fetchImpl,
497
+ serverUrl,
498
+ authToken,
499
+ });
500
+ if (attempt >= maxAttempts) return exitCode;
501
+ output.write(`[nolo] Connector websocket disconnected. Reconnecting in ${reconnectDelayMs}ms.\n`);
502
+ await sleep(reconnectDelayMs);
503
+ }
504
+ return 0;
505
+ }
332
506
 
333
507
  if (hasFlag(args, "--watch")) {
334
508
  output.write(`Connecting machine heartbeat loop: ${machine.name} (${machine.platform}/${machine.arch})\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nolo-cli",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "type": "module",
5
5
  "description": "Agent-first terminal workspace for Nolo",
6
6
  "bin": {
@@ -17,10 +17,13 @@
17
17
  "agentRunCommand.ts",
18
18
  "authCommands.ts",
19
19
  "commandRegistry.ts",
20
+ "connectorRunArtifact.ts",
20
21
  "connectorWebSocketTarget.ts",
21
22
  "defaultServer.ts",
22
23
  "machineCommands.ts",
24
+ "taskRunCommand.ts",
23
25
  "runtimeModeArgs.ts",
26
+ "tableCommands.ts",
24
27
  "updateCommands.ts",
25
28
  "client/**/*.ts",
26
29
  "tui/**/*.ts",