nolo-cli 0.1.17 → 0.1.19

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.
@@ -33,6 +33,7 @@ export type AgentRuntimeSaveTurnInput = {
33
33
  agentKey: string;
34
34
  messages: AgentRuntimeChatMessage[];
35
35
  result: AgentRuntimeResult;
36
+ continueDialogId?: string;
36
37
  };
37
38
 
38
39
  export type AgentRuntimeHostAdapter = {
@@ -44,6 +44,8 @@ export async function runLocalAgentTurn(
44
44
  const history = input.continueDialogId
45
45
  ? await input.adapter.loadDialogHistory(input.continueDialogId)
46
46
  : [];
47
+ const promptMessageCount = agentConfig.prompt?.trim() ? 1 : 0;
48
+ const turnStartIndex = promptMessageCount + history.length;
47
49
  const messages = buildMessages({
48
50
  prompt: agentConfig.prompt,
49
51
  history,
@@ -76,17 +78,23 @@ export async function runLocalAgentTurn(
76
78
  role: "tool",
77
79
  content: toolResult.content,
78
80
  tool_call_id: toolCall.id,
81
+ ...(toolResult.metadata ? { tool_result_metadata: toolResult.metadata } : {}),
79
82
  });
80
83
  }
81
84
  }
82
85
  result = result!;
86
+ messages.push({
87
+ role: "assistant",
88
+ content: result.content,
89
+ });
83
90
  const saved = await input.adapter.saveTurn({
84
91
  agentKey: agentConfig.key,
85
- messages,
92
+ messages: messages.slice(turnStartIndex),
86
93
  result: {
87
94
  ...result,
88
95
  ...(toolCallCount > 0 ? { toolCallCount } : {}),
89
96
  },
97
+ ...(input.continueDialogId ? { continueDialogId: input.continueDialogId } : {}),
90
98
  });
91
99
 
92
100
  return {
@@ -33,6 +33,7 @@ export interface AgentRuntimeChatMessage {
33
33
  content: AgentRuntimeMessageContent;
34
34
  tool_call_id?: string;
35
35
  tool_calls?: AgentRuntimeToolCall[];
36
+ tool_result_metadata?: Record<string, unknown>;
36
37
  reasoning_content?: string;
37
38
  cybotKey?: string;
38
39
  agentKey?: string;
@@ -0,0 +1,26 @@
1
+ export const PLATFORM_DEMO_USER_ID = "b2e06f801f";
2
+ export const FRONTEND_IMPLEMENTER_AGENT_ID = "01FRONTENDAG0000000115N4E1";
3
+ export const FRONTEND_IMPLEMENTER_AGENT_KEY =
4
+ `agent-${PLATFORM_DEMO_USER_ID}-${FRONTEND_IMPLEMENTER_AGENT_ID}`;
5
+
6
+ const AGENT_ALIAS_TO_KEY: Record<string, string> = {
7
+ "frontend-implementer": FRONTEND_IMPLEMENTER_AGENT_KEY,
8
+ "frontend-agent": FRONTEND_IMPLEMENTER_AGENT_KEY,
9
+ frontend: FRONTEND_IMPLEMENTER_AGENT_KEY,
10
+ "front-end": FRONTEND_IMPLEMENTER_AGENT_KEY,
11
+ "前端": FRONTEND_IMPLEMENTER_AGENT_KEY,
12
+ "前端实现员": FRONTEND_IMPLEMENTER_AGENT_KEY,
13
+ };
14
+
15
+ function parseAgentKeyFromInput(raw: string): string {
16
+ const trimmed = raw.trim();
17
+ if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
18
+ const url = new URL(trimmed);
19
+ return url.pathname.replace(/^\/+/, "");
20
+ }
21
+ return trimmed;
22
+ }
23
+
24
+ export function resolveCliAgentKeyInput(raw: string): string {
25
+ return AGENT_ALIAS_TO_KEY[raw.trim().toLowerCase()] ?? parseAgentKeyFromInput(raw);
26
+ }
@@ -1,6 +1,7 @@
1
1
  import { DEFAULT_NOLO_SERVER_URL } from "./defaultServer";
2
2
  import { getDefaultCliLocalRuntimeDb } from "./localRuntimeDb";
3
3
  import type { CliLocalRuntimeDb } from "./client/localRuntimeAdapter";
4
+ import { resolveCliAgentKeyInput } from "./agentAliases";
4
5
 
5
6
  type EnvLike = Record<string, string | undefined>;
6
7
 
@@ -40,7 +41,7 @@ function positionalArgs(args: string[]) {
40
41
  export function parseAgentPullArgs(args: string[]): ParsedAgentPullArgs | null {
41
42
  const agentKey = readFlagValue(args, "--agent") ?? positionalArgs(args)[0];
42
43
  if (!agentKey?.trim()) return null;
43
- return { agentKey: agentKey.trim() };
44
+ return { agentKey: resolveCliAgentKeyInput(agentKey) };
44
45
  }
45
46
 
46
47
  function resolveServerUrl(env: EnvLike) {
@@ -2,7 +2,8 @@ import { DEFAULT_NOLO_SERVER_URL } from "./defaultServer";
2
2
  import { runAgentTurn, type RunAgentTurnResult } from "./client/agentRun";
3
3
  import type { AgentRuntimeRequestedMode } from "./agentRuntimeLocal";
4
4
  import { existsSync, readFileSync } from "node:fs";
5
- import { extname, resolve } from "node:path";
5
+ import { extname, join, resolve } from "node:path";
6
+ import { resolveCliAgentKeyInput } from "./agentAliases";
6
7
 
7
8
  type EnvLike = Record<string, string | undefined>;
8
9
 
@@ -15,6 +16,7 @@ type AgentRunCommandDeps = {
15
16
  scriptDir: string;
16
17
  output?: OutputLike;
17
18
  runner?: typeof runAgentTurn;
19
+ prepareShellWorktree?: typeof prepareShellWorktree;
18
20
  };
19
21
 
20
22
  type ParsedAgentRunArgs = {
@@ -82,7 +84,7 @@ export function parseAgentRunArgs(args: string[]): ParsedAgentRunArgs | null {
82
84
  ...readRepeatedFlagValues(args, "--image-url"),
83
85
  ];
84
86
  return {
85
- agentKey,
87
+ agentKey: resolveCliAgentKeyInput(agentKey),
86
88
  message: message.trim(),
87
89
  imageUrls,
88
90
  allowShell: args.includes("--dangerously-allow-shell"),
@@ -117,6 +119,49 @@ function resolveServerUrl(env: EnvLike) {
117
119
  return (env.NOLO_SERVER || env.BASE_URL || DEFAULT_NOLO_SERVER_URL).replace(/\/+$/, "");
118
120
  }
119
121
 
122
+ function safePathSegment(input: string) {
123
+ return input.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "agent";
124
+ }
125
+
126
+ function createTaskId() {
127
+ return Date.now().toString(36).toUpperCase();
128
+ }
129
+
130
+ async function runGit(args: string[], cwd: string) {
131
+ const proc = Bun.spawn(["git", ...args], {
132
+ cwd,
133
+ stdout: "pipe",
134
+ stderr: "pipe",
135
+ stdin: "ignore",
136
+ });
137
+ const [stdout, stderr, exitCode] = await Promise.all([
138
+ new Response(proc.stdout).text(),
139
+ new Response(proc.stderr).text(),
140
+ proc.exited,
141
+ ]);
142
+ if (exitCode !== 0) {
143
+ throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`);
144
+ }
145
+ return stdout.trim();
146
+ }
147
+
148
+ export async function prepareShellWorktree(args: {
149
+ agentKey: string;
150
+ cwd?: string;
151
+ env?: EnvLike;
152
+ }) {
153
+ if (args.env?.NOLO_LOCAL_WORKTREE) return args.env.NOLO_LOCAL_WORKTREE;
154
+ const cwd = args.cwd ?? process.cwd();
155
+ const root = await runGit(["rev-parse", "--show-toplevel"], cwd);
156
+ const parent = join(root, ".worktrees");
157
+ const safeAgent = safePathSegment(args.agentKey);
158
+ const taskId = createTaskId();
159
+ const worktreePath = join(parent, `nolo-agent-${safeAgent}-${taskId}`);
160
+ const branchName = `nolo-agent-${safeAgent}-${taskId}`;
161
+ await runGit(["worktree", "add", worktreePath, "-b", branchName, "HEAD"], root);
162
+ return worktreePath;
163
+ }
164
+
120
165
  function writeUsage(output: OutputLike) {
121
166
  output.write(
122
167
  "Usage: nolo agent run <agent> <message> [--local|--server|--auto] [--continue <dialogId>]\n" +
@@ -134,8 +179,28 @@ export async function runAgentRunCommand(args: string[], deps: AgentRunCommandDe
134
179
  }
135
180
 
136
181
  const runner = deps.runner ?? runAgentTurn;
182
+ let localRuntimeCwd: string | undefined;
183
+ if (parsed.allowShell) {
184
+ try {
185
+ localRuntimeCwd = await (deps.prepareShellWorktree ?? prepareShellWorktree)({
186
+ agentKey: parsed.agentKey,
187
+ env,
188
+ });
189
+ } catch (error) {
190
+ output.write(
191
+ "[nolo] Could not prepare a local shell worktree.\n" +
192
+ "Run from inside a git repository, or set NOLO_LOCAL_WORKTREE to an existing isolated checkout.\n" +
193
+ `Reason: ${error instanceof Error ? error.message : String(error)}\n`
194
+ );
195
+ return 1;
196
+ }
197
+ }
137
198
  const runEnv = parsed.allowShell
138
- ? { ...env, NOLO_LOCAL_SHELL_MODE: "worktree" }
199
+ ? {
200
+ ...env,
201
+ NOLO_LOCAL_SHELL_MODE: "worktree",
202
+ ...(localRuntimeCwd ? { NOLO_LOCAL_WORKTREE: localRuntimeCwd } : {}),
203
+ }
139
204
  : env;
140
205
  const result: RunAgentTurnResult = await runner({
141
206
  agentName: parsed.agentKey,
@@ -146,6 +211,7 @@ export async function runAgentRunCommand(args: string[], deps: AgentRunCommandDe
146
211
  scriptDir: deps.scriptDir,
147
212
  env: runEnv,
148
213
  output,
214
+ ...(localRuntimeCwd ? { localRuntimeCwd } : {}),
149
215
  ...(parsed.runtimeMode ? { runtimeMode: parsed.runtimeMode } : {}),
150
216
  ...(parsed.continueDialogId ? { continueDialogId: parsed.continueDialogId } : {}),
151
217
  });
@@ -24,6 +24,7 @@ type RunAgentTurnOptions = {
24
24
  runtimeMode?: AgentRuntimeRequestedMode;
25
25
  localRuntimeAdapter?: AgentRuntimeHostAdapter;
26
26
  localRuntimeAdapterFactory?: (env: EnvLike) => AgentRuntimeHostAdapter;
27
+ localRuntimeCwd?: string;
27
28
  scriptPathExists?: (path: string) => boolean;
28
29
  fetchImpl?: typeof fetch;
29
30
  };
@@ -61,6 +62,7 @@ function buildDefaultLocalRuntimeAdapter(options: RunAgentTurnOptions) {
61
62
  return createCliLocalRuntimeAdapter({
62
63
  env: options.env,
63
64
  fetchImpl: options.fetchImpl,
65
+ cwd: options.localRuntimeCwd,
64
66
  });
65
67
  }
66
68
 
@@ -81,7 +81,7 @@ describe("CLI local runtime adapter", () => {
81
81
  expect(result).toMatchObject({
82
82
  content: "local adapter ok",
83
83
  model: "gpt-4.1-mini",
84
- dialogId: "01LOCAL",
84
+ dialogId: "dialog-existing",
85
85
  });
86
86
  expect(requests).toEqual([{
87
87
  url: "http://127.0.0.1:11434/v1/chat/completions",
@@ -97,29 +97,210 @@ describe("CLI local runtime adapter", () => {
97
97
  },
98
98
  }]);
99
99
  expect(batchOps.map((op) => op.key)).toEqual([
100
- "dialog-user-1-01LOCAL",
101
- "dialog-01LOCAL-msg-user",
102
- "dialog-01LOCAL-msg-assistant",
100
+ "dialog-user-1-dialog-existing",
101
+ "dialog-dialog-existing-msg-1710000000000-001",
102
+ "dialog-dialog-existing-msg-1710000000000-002",
103
103
  ]);
104
- expect(store.get("dialog-user-1-01LOCAL")).toMatchObject({
105
- id: "01LOCAL",
106
- dbKey: "dialog-user-1-01LOCAL",
104
+ expect(store.get("dialog-user-1-dialog-existing")).toMatchObject({
105
+ id: "dialog-existing",
106
+ dbKey: "dialog-user-1-dialog-existing",
107
107
  type: "dialog",
108
108
  primaryAgentKey: "agent-user-1-frontend",
109
109
  status: "done",
110
110
  });
111
- expect(store.get("dialog-01LOCAL-msg-user")).toMatchObject({
112
- dialogId: "01LOCAL",
111
+ expect(store.get("dialog-dialog-existing-msg-1710000000000-001")).toMatchObject({
112
+ dialogId: "dialog-existing",
113
113
  role: "user",
114
114
  content: "make it cleaner",
115
115
  });
116
- expect(store.get("dialog-01LOCAL-msg-assistant")).toMatchObject({
117
- dialogId: "01LOCAL",
116
+ expect(store.get("dialog-dialog-existing-msg-1710000000000-002")).toMatchObject({
117
+ dialogId: "dialog-existing",
118
118
  role: "assistant",
119
119
  content: "local adapter ok",
120
120
  });
121
121
  });
122
122
 
123
+ test("saves local tool call trace and shell metadata into the local dialog", async () => {
124
+ const store = new Map<string, any>([
125
+ ["agent-user-1-shell", {
126
+ dbKey: "agent-user-1-shell",
127
+ id: "shell",
128
+ prompt: "Use shell.",
129
+ model: "gpt-4.1-mini",
130
+ }],
131
+ ]);
132
+ const adapter = createCliLocalRuntimeAdapter({
133
+ env: {
134
+ NOLO_LOCAL_USER_ID: "user-1",
135
+ NOLO_LOCAL_OPENAI_BASE_URL: "http://127.0.0.1:11434/v1",
136
+ NOLO_LOCAL_SHELL_MODE: "worktree",
137
+ },
138
+ db: {
139
+ get: async (key) => {
140
+ if (!store.has(key)) throw new Error(`not found: ${key}`);
141
+ return store.get(key);
142
+ },
143
+ put: async (key, value) => {
144
+ store.set(key, value);
145
+ },
146
+ batch: async (ops) => {
147
+ for (const op of ops) {
148
+ if (op.type === "put") store.set(op.key, op.value);
149
+ }
150
+ },
151
+ iterator: () => (async function* () {})(),
152
+ },
153
+ cwd: import.meta.dir,
154
+ now: () => 1710000000000,
155
+ createId: () => "01TRACE",
156
+ fetchImpl: async (_url, init) => {
157
+ const body = JSON.parse(String(init?.body));
158
+ const hasToolResult = body.messages.some((message: any) => message.role === "tool");
159
+ if (!hasToolResult) {
160
+ return Response.json({
161
+ choices: [{
162
+ message: {
163
+ content: "",
164
+ tool_calls: [{
165
+ id: "call-shell",
166
+ type: "function",
167
+ function: {
168
+ name: "execShell",
169
+ arguments: JSON.stringify({ cmd: "printf trace-ok" }),
170
+ },
171
+ }],
172
+ },
173
+ }],
174
+ });
175
+ }
176
+ return Response.json({
177
+ choices: [{ message: { content: "done" } }],
178
+ });
179
+ },
180
+ });
181
+
182
+ const result = await runLocalAgentTurn({
183
+ adapter,
184
+ agentRef: "shell",
185
+ input: "inspect",
186
+ });
187
+
188
+ expect(result.dialogId).toBe("01TRACE");
189
+ expect(store.get("dialog-user-1-01TRACE")).toMatchObject({
190
+ toolCallCount: 1,
191
+ localRuntime: expect.objectContaining({
192
+ host: "cli",
193
+ worktreePath: import.meta.dir,
194
+ }),
195
+ });
196
+ const messages = [...store.entries()]
197
+ .filter(([key]) => key.startsWith("dialog-01TRACE-msg-"))
198
+ .sort(([a], [b]) => a.localeCompare(b))
199
+ .map(([, value]) => value);
200
+ expect(messages.map((message) => message.role)).toEqual([
201
+ "user",
202
+ "assistant",
203
+ "tool",
204
+ "assistant",
205
+ ]);
206
+ expect(messages[1]).toMatchObject({
207
+ tool_calls: [{
208
+ id: "call-shell",
209
+ function: { name: "execShell" },
210
+ }],
211
+ });
212
+ expect(messages[2]).toMatchObject({
213
+ role: "tool",
214
+ toolCallId: "call-shell",
215
+ metadata: { exitCode: 0 },
216
+ });
217
+ expect(messages[2].content).toContain("trace-ok");
218
+ expect(messages[3]).toMatchObject({
219
+ role: "assistant",
220
+ content: "done",
221
+ });
222
+ });
223
+
224
+ test("continues a local dialog instead of creating a new one", async () => {
225
+ const store = new Map<string, any>([
226
+ ["agent-user-1-frontend", {
227
+ dbKey: "agent-user-1-frontend",
228
+ id: "frontend",
229
+ prompt: "Fix UI",
230
+ model: "gpt-4.1-mini",
231
+ }],
232
+ ["dialog-user-1-dialog-existing", {
233
+ dbKey: "dialog-user-1-dialog-existing",
234
+ id: "dialog-existing",
235
+ type: "dialog",
236
+ userId: "user-1",
237
+ title: "Existing dialog",
238
+ }],
239
+ ["dialog-dialog-existing-msg-001", {
240
+ dbKey: "dialog-dialog-existing-msg-001",
241
+ id: "msg-001",
242
+ dialogId: "dialog-existing",
243
+ role: "assistant",
244
+ content: "previous answer",
245
+ }],
246
+ ]);
247
+ const adapter = createCliLocalRuntimeAdapter({
248
+ env: {
249
+ NOLO_LOCAL_USER_ID: "user-1",
250
+ NOLO_LOCAL_OPENAI_BASE_URL: "http://127.0.0.1:11434/v1",
251
+ },
252
+ db: {
253
+ get: async (key) => {
254
+ if (!store.has(key)) throw new Error(`not found: ${key}`);
255
+ return store.get(key);
256
+ },
257
+ put: async (key, value) => {
258
+ store.set(key, value);
259
+ },
260
+ batch: async (ops) => {
261
+ for (const op of ops) {
262
+ if (op.type === "put") store.set(op.key, op.value);
263
+ }
264
+ },
265
+ iterator: ({ gte, lte }) => (async function* () {
266
+ for (const entry of [...store.entries()].sort(([a], [b]) => a.localeCompare(b))) {
267
+ if (entry[0] >= gte && entry[0] <= lte) yield entry;
268
+ }
269
+ })(),
270
+ },
271
+ now: () => 1710000000000,
272
+ createId: () => "SHOULDNOTUSE",
273
+ fetchImpl: async (_url, init) => {
274
+ const body = JSON.parse(String(init?.body));
275
+ expect(body.messages).toContainEqual({
276
+ role: "assistant",
277
+ content: "previous answer",
278
+ });
279
+ return Response.json({
280
+ choices: [{ message: { content: "continued" } }],
281
+ });
282
+ },
283
+ });
284
+
285
+ const result = await runLocalAgentTurn({
286
+ adapter,
287
+ agentRef: "frontend",
288
+ input: "continue",
289
+ continueDialogId: "dialog-existing",
290
+ });
291
+
292
+ expect(result.dialogId).toBe("dialog-existing");
293
+ expect(store.has("dialog-user-1-SHOULDNOTUSE")).toBe(false);
294
+ expect(store.get("dialog-user-1-dialog-existing")).toMatchObject({
295
+ id: "dialog-existing",
296
+ title: "Existing dialog",
297
+ status: "done",
298
+ });
299
+ expect([...store.keys()].filter((key) => key.startsWith("dialog-dialog-existing-msg-"))).toContain(
300
+ "dialog-dialog-existing-msg-1710000000000-001"
301
+ );
302
+ });
303
+
123
304
  test("passes image_url message parts through to OpenAI-compatible providers", async () => {
124
305
  const requests: Array<{ body: any }> = [];
125
306
  const adapter = createCliLocalRuntimeAdapter({
@@ -215,7 +396,7 @@ describe("CLI local runtime adapter", () => {
215
396
  expect(result.content).toContain("README.md");
216
397
  });
217
398
 
218
- test("advertises execShell to OpenAI-compatible providers when the agent declares it", async () => {
399
+ test("advertises execShell to OpenAI-compatible providers when shell mode is enabled", async () => {
219
400
  const requests: Array<{ body: any }> = [];
220
401
  const adapter = createCliLocalRuntimeAdapter({
221
402
  env: {
@@ -228,7 +409,6 @@ describe("CLI local runtime adapter", () => {
228
409
  dbKey: "agent-local-shell",
229
410
  prompt: "Use shell.",
230
411
  model: "gpt-4.1-mini",
231
- toolNames: ["execShell"],
232
412
  }),
233
413
  put: async () => {},
234
414
  batch: async () => {},
@@ -67,8 +67,14 @@ function buildExecShellTool() {
67
67
  };
68
68
  }
69
69
 
70
- function buildOpenAiTools(toolNames: string[] = []) {
71
- return toolNames.includes("execShell") ? [buildExecShellTool()] : [];
70
+ function shellModeEnabled(env: EnvLike) {
71
+ return ["worktree", "dangerous", "1", "true"].includes(env.NOLO_LOCAL_SHELL_MODE || "");
72
+ }
73
+
74
+ function buildOpenAiTools(args: { toolNames?: string[]; env: EnvLike }) {
75
+ return args.toolNames?.includes("execShell") || shellModeEnabled(args.env)
76
+ ? [buildExecShellTool()]
77
+ : [];
72
78
  }
73
79
 
74
80
  function parseToolArguments(raw: string) {
@@ -155,6 +161,8 @@ function toOpenAiMessages(messages: AgentRuntimeChatMessage[]) {
155
161
  return messages.map((message) => ({
156
162
  role: message.role,
157
163
  content: message.content ?? "",
164
+ ...(message.tool_call_id ? { tool_call_id: message.tool_call_id } : {}),
165
+ ...(Array.isArray(message.tool_calls) ? { tool_calls: message.tool_calls } : {}),
158
166
  }));
159
167
  }
160
168
 
@@ -186,8 +194,9 @@ async function writeDialog(args: {
186
194
  userId: string;
187
195
  now: () => number;
188
196
  createId: () => string;
197
+ cwd?: string;
189
198
  }) {
190
- const dialogId = args.createId();
199
+ const dialogId = args.input.continueDialogId || args.createId();
191
200
  const now = args.now();
192
201
  const nowIso = new Date(now).toISOString();
193
202
  const dialogKey = `dialog-${args.userId}-${dialogId}`;
@@ -200,56 +209,74 @@ async function writeDialog(args: {
200
209
  .map((part) => part.text)
201
210
  .join(" ")
202
211
  : "";
212
+ let existingDialog: any = null;
213
+ if (args.input.continueDialogId) {
214
+ try {
215
+ existingDialog = await args.db.get(dialogKey);
216
+ } catch {
217
+ existingDialog = null;
218
+ }
219
+ }
220
+ const messageOps = args.input.messages
221
+ .filter((message) => message.role !== "system")
222
+ .map((message, index) => {
223
+ const id = `${now}-${String(index + 1).padStart(3, "0")}`;
224
+ const key = `dialog-${dialogId}-msg-${id}`;
225
+ return {
226
+ type: "put" as const,
227
+ key,
228
+ value: {
229
+ id,
230
+ dbKey: key,
231
+ dialogId,
232
+ role: message.role,
233
+ content: message.content ?? "",
234
+ ...(message.role === "user" ? { userId: args.userId } : {}),
235
+ ...(message.role === "assistant" ? {
236
+ agentKey: args.input.agentKey,
237
+ cybotKey: args.input.agentKey,
238
+ } : {}),
239
+ ...(message.tool_call_id ? { toolCallId: message.tool_call_id } : {}),
240
+ ...(Array.isArray(message.tool_calls) ? { tool_calls: message.tool_calls } : {}),
241
+ ...(message.tool_result_metadata ? { metadata: message.tool_result_metadata } : {}),
242
+ createdAt: nowIso,
243
+ },
244
+ };
245
+ });
203
246
  const ops: Array<{ type: "put"; key: string; value: any }> = [
204
247
  {
205
248
  type: "put",
206
249
  key: dialogKey,
207
250
  value: {
251
+ ...(existingDialog && typeof existingDialog === "object" ? existingDialog : {}),
208
252
  id: dialogId,
209
253
  dbKey: dialogKey,
210
254
  type: "dialog",
211
255
  userId: args.userId,
212
256
  cybots: [args.input.agentKey],
213
257
  primaryAgentKey: args.input.agentKey,
214
- title: lastUserText.trim()
215
- ? lastUserText.trim().slice(0, 80)
216
- : "Local agent run",
258
+ title: typeof existingDialog?.title === "string" && existingDialog.title.trim()
259
+ ? existingDialog.title
260
+ : lastUserText.trim()
261
+ ? lastUserText.trim().slice(0, 80)
262
+ : "Local agent run",
217
263
  status: "done",
218
264
  triggerType: "cli-local",
219
265
  executionMode: "foreground",
220
- createdAt: nowIso,
266
+ createdAt: existingDialog?.createdAt ?? nowIso,
221
267
  updatedAt: nowIso,
222
268
  finishedAt: now,
223
269
  usage: args.input.result.usage,
270
+ ...(typeof args.input.result.toolCallCount === "number"
271
+ ? { toolCallCount: args.input.result.toolCallCount }
272
+ : {}),
273
+ localRuntime: {
274
+ host: "cli",
275
+ ...(args.cwd ? { worktreePath: args.cwd } : {}),
276
+ },
224
277
  },
225
278
  },
226
- {
227
- type: "put",
228
- key: `dialog-${dialogId}-msg-user`,
229
- value: {
230
- id: "msg-user",
231
- dbKey: `dialog-${dialogId}-msg-user`,
232
- dialogId,
233
- role: "user",
234
- content: lastUser?.content ?? "",
235
- userId: args.userId,
236
- createdAt: nowIso,
237
- },
238
- },
239
- {
240
- type: "put",
241
- key: `dialog-${dialogId}-msg-assistant`,
242
- value: {
243
- id: "msg-assistant",
244
- dbKey: `dialog-${dialogId}-msg-assistant`,
245
- dialogId,
246
- role: "assistant",
247
- content: args.input.result.content,
248
- agentKey: args.input.agentKey,
249
- cybotKey: args.input.agentKey,
250
- createdAt: nowIso,
251
- },
252
- },
279
+ ...messageOps,
253
280
  ];
254
281
  await args.db.batch(ops);
255
282
  return { dialogId };
@@ -287,11 +314,15 @@ export function createCliLocalRuntimeAdapter(
287
314
  userId,
288
315
  now,
289
316
  createId,
317
+ cwd: deps.cwd,
290
318
  }),
291
319
  resolveProvider: async (agentConfig) => ({
292
320
  model: agentConfig.model || "gpt-4.1-mini",
293
321
  complete: async (messages) => {
294
- const tools = buildOpenAiTools(agentConfig.toolNames ?? []);
322
+ const tools = buildOpenAiTools({
323
+ toolNames: agentConfig.toolNames,
324
+ env: deps.env,
325
+ });
295
326
  const res = await fetchImpl(`${resolveOpenAiCompatibleBaseUrl(deps.env)}/chat/completions`, {
296
327
  method: "POST",
297
328
  headers: {
@@ -6,7 +6,7 @@ describe("CLI local tool policy", () => {
6
6
  test("allows execShell in explicit worktree shell mode", () => {
7
7
  expect(resolveLocalToolPolicy({
8
8
  env: { NOLO_LOCAL_SHELL_MODE: "worktree" },
9
- agentToolNames: ["execShell"],
9
+ agentToolNames: [],
10
10
  toolName: "execShell",
11
11
  })).toEqual({ allowed: true, toolName: "execShell" });
12
12
  });
@@ -27,17 +27,13 @@ export function resolveLocalToolPolicy(args: {
27
27
  }): LocalToolPolicyDecision {
28
28
  if (args.toolName === "execShell") {
29
29
  const shellMode = args.env.NOLO_LOCAL_SHELL_MODE || "";
30
- const agentTools = new Set(args.agentToolNames ?? []);
31
- if (
32
- agentTools.has("execShell") &&
33
- ["worktree", "dangerous", "1", "true"].includes(shellMode)
34
- ) {
30
+ if (["worktree", "dangerous", "1", "true"].includes(shellMode)) {
35
31
  return { allowed: true, toolName: args.toolName };
36
32
  }
37
33
  return {
38
34
  allowed: false,
39
35
  toolName: args.toolName,
40
- reason: "execShell requires NOLO_LOCAL_SHELL_MODE=worktree and an agent that declares execShell.",
36
+ reason: "execShell requires NOLO_LOCAL_SHELL_MODE=worktree.",
41
37
  };
42
38
  }
43
39
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nolo-cli",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "type": "module",
5
5
  "description": "Agent-first terminal workspace for Nolo",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "index.ts",
12
12
  "agentRuntimeLocal.ts",
13
+ "agentAliases.ts",
13
14
  "localRuntimeDb.ts",
14
15
  "agentRuntimeCommands.ts",
15
16
  "agentPullCommand.ts",