@love-moon/ai-sdk 0.14.0 → 0.15.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # @love-moon/ai-sdk
2
2
 
3
+ ## 0.15.1
4
+
5
+ ## 0.15.0
6
+
7
+ ### Minor Changes
8
+
9
+ - cc1e290: New Terminal and Resume Session previews in the create-task dialog.
10
+
11
+ - `@love-moon/ai-sdk`: `listSessions` (claude, codex) returns `preview` with
12
+ the first user message, the assistant reply to it, and the session's last
13
+ message.
14
+ - The daemon passes the preview to `list_backend_sessions` as
15
+ `first_user_message`, `first_reply`, `last_message` and `last_message_role`.
16
+ - A PTY task with no project path now starts its shell in `$HOME`. The
17
+ terminal log stays in the dated workspace directory.
18
+
19
+ - 55b65db: `conductor remote mcp`: structured tools for a task whose worktree lives on
20
+ another daemon (RFC 0040).
21
+
22
+ - New `conductor remote mcp --host <daemon> --root <dir> [--cwd <dir>]`, a
23
+ stdio MCP server with `remote_read`, `remote_edit`, `remote_write`,
24
+ `remote_grep`, `remote_glob` and `remote_bash`, bound to that directory.
25
+ - For a `launch_config.remoteWorktree` task the daemon passes the binding to
26
+ fire (`CONDUCTOR_REMOTE_WORKTREE`), and fire attaches the server to Claude
27
+ (`mcpServers`) and Codex (`-c mcp_servers.*`). The agent token is forwarded
28
+ by env only, never on argv.
29
+ - `@love-moon/ai-sdk`: the Codex app-server session accepts `configOverrides`,
30
+ passed to the app-server as `-c key=value`.
31
+
3
32
  ## 0.14.0
4
33
 
5
34
  ### Minor Changes
@@ -307,6 +307,7 @@ export class CodexAppServerSession extends EventEmitter {
307
307
  },
308
308
  commandLine: options.commandLine,
309
309
  enableGoals: this.goalMode || options.enableGoals === true,
310
+ configOverrides: options.configOverrides,
310
311
  });
311
312
  this.transport.on("notification", ({ method, params }) => {
312
313
  void this.handleNotification(method, params);
@@ -24,5 +24,13 @@ export function listSessions(options?: {}): Promise<{
24
24
  cwd: any;
25
25
  title: string | null;
26
26
  updatedAt: any;
27
+ preview: {
28
+ firstUserMessage: string | null;
29
+ firstReply: string | null;
30
+ lastMessage: {
31
+ role: any;
32
+ text: string | null;
33
+ } | null;
34
+ };
27
35
  }[]>;
28
36
  export const BACKEND: "claude";
@@ -3,7 +3,7 @@ import { promises as fsp } from "node:fs";
3
3
  import path from "node:path";
4
4
  import readline from "node:readline";
5
5
  import { resolveClaudeConfigDirs } from "../manager/paths.js";
6
- import { buildResumeContext, isExistingDirectory, normalizeSessionId, normalizeSessionTitle, pathExists, readJsonlHeadEntries, resolveSessionRunDirectory, } from "./shared.js";
6
+ import { buildResumeContext, buildSessionPreview, isExistingDirectory, normalizeSessionId, normalizeSessionTitle, pathExists, readJsonlHeadEntries, readJsonlTailEntries, resolveSessionRunDirectory, } from "./shared.js";
7
7
  export const BACKEND = "claude";
8
8
  export function buildCliArgs(sessionId) {
9
9
  const normalizedSessionId = normalizeSessionId(sessionId);
@@ -156,12 +156,14 @@ export async function listSessions(options = {}) {
156
156
  const sessions = [];
157
157
  for (const candidate of selected) {
158
158
  const entries = await readJsonlHeadEntries(candidate.sessionFilePath);
159
+ const tailEntries = await readJsonlTailEntries(candidate.sessionFilePath);
159
160
  sessions.push({
160
161
  sessionId: candidate.sessionId,
161
162
  sessionFilePath: candidate.sessionFilePath,
162
163
  cwd: extractHeadCwd(entries),
163
164
  title: extractClaudeTitle(entries),
164
165
  updatedAt: candidate.updatedAt,
166
+ preview: buildSessionPreview(extractClaudeMessages(entries), extractClaudeMessages(tailEntries)),
165
167
  });
166
168
  }
167
169
  return sessions;
@@ -174,6 +176,31 @@ function extractHeadCwd(entries) {
174
176
  }
175
177
  return null;
176
178
  }
179
+ /**
180
+ * User/assistant text messages. Tool calls, tool results, meta entries and
181
+ * wrapped slash-command output (<command-name>, <local-command-stdout>) are skipped.
182
+ */
183
+ function extractClaudeMessages(entries) {
184
+ const messages = [];
185
+ for (const entry of entries) {
186
+ if ((entry?.type !== "user" && entry?.type !== "assistant") || entry?.isMeta === true) {
187
+ continue;
188
+ }
189
+ const content = entry?.message?.content;
190
+ const text = typeof content === "string"
191
+ ? content
192
+ : Array.isArray(content)
193
+ ? content
194
+ .filter((item) => item?.type === "text" && typeof item.text === "string")
195
+ .map((item) => item.text)
196
+ .join("\n")
197
+ : "";
198
+ if (text.trim() && !text.trim().startsWith("<")) {
199
+ messages.push({ role: entry.type, text });
200
+ }
201
+ }
202
+ return messages;
203
+ }
177
204
  function extractClaudeTitle(entries) {
178
205
  for (const entry of entries) {
179
206
  if (entry?.type !== "user" || entry?.isMeta === true) {
@@ -24,5 +24,13 @@ export function listSessions(options?: {}): Promise<{
24
24
  cwd: string | null;
25
25
  title: string | null;
26
26
  updatedAt: number;
27
+ preview: {
28
+ firstUserMessage: string | null;
29
+ firstReply: string | null;
30
+ lastMessage: {
31
+ role: any;
32
+ text: string | null;
33
+ } | null;
34
+ };
27
35
  }[]>;
28
36
  export const BACKEND: "codex";
@@ -1,6 +1,6 @@
1
1
  import { promises as fsp } from "node:fs";
2
2
  import path from "node:path";
3
- import { buildResumeContext, isExistingDirectory, iterateJsonlEntries, normalizeSessionId, normalizeSessionTitle, readJsonlHeadEntries, resolveHomeDir, resolveSessionRunDirectory, } from "./shared.js";
3
+ import { buildResumeContext, buildSessionPreview, isExistingDirectory, iterateJsonlEntries, normalizeSessionId, normalizeSessionTitle, readJsonlHeadEntries, readJsonlTailEntries, resolveHomeDir, resolveSessionRunDirectory, } from "./shared.js";
4
4
  export const BACKEND = "codex";
5
5
  export function buildCliArgs(sessionId) {
6
6
  const normalizedSessionId = normalizeSessionId(sessionId);
@@ -111,12 +111,14 @@ export async function listSessions(options = {}) {
111
111
  continue;
112
112
  }
113
113
  const metaCwd = meta?.payload?.cwd;
114
+ const tailEntries = await readJsonlTailEntries(candidate.filePath);
114
115
  sessions.push({
115
116
  sessionId,
116
117
  sessionFilePath: candidate.filePath,
117
118
  cwd: typeof metaCwd === "string" && metaCwd.trim() ? metaCwd.trim() : null,
118
119
  title: extractCodexTitle(headEntries),
119
120
  updatedAt: candidate.updatedAt,
121
+ preview: buildSessionPreview(extractCodexMessages(headEntries), extractCodexMessages(tailEntries)),
120
122
  });
121
123
  }
122
124
  return sessions;
@@ -126,6 +128,40 @@ function extractSessionIdFromFileName(filePath) {
126
128
  const match = path.basename(filePath).match(SESSION_ID_IN_FILE_NAME);
127
129
  return match ? match[0] : "";
128
130
  }
131
+ // The event stream's user/agent messages: what the user typed and the
132
+ // assistant's replies, without injected context or tool output.
133
+ const CODEX_MESSAGE_ROLES = { user_message: "user", agent_message: "assistant" };
134
+ const CODEX_ITEM_TEXT_TYPES = { user: "input_text", assistant: "output_text" };
135
+ function extractCodexMessages(entries) {
136
+ const messages = [];
137
+ for (const entry of entries) {
138
+ const role = entry?.type === "event_msg" ? CODEX_MESSAGE_ROLES[entry?.payload?.type] : null;
139
+ const text = entry?.payload?.message;
140
+ if (role && typeof text === "string" && text.trim()) {
141
+ messages.push({ role, text });
142
+ }
143
+ }
144
+ if (messages.length > 0) {
145
+ return messages;
146
+ }
147
+ // Sessions without those events only carry response items; skip injected
148
+ // wrappers such as <environment_context>, as the title does.
149
+ for (const entry of entries) {
150
+ const payload = entry?.type === "response_item" ? entry.payload : null;
151
+ const textType = payload?.type === "message" ? CODEX_ITEM_TEXT_TYPES[payload.role] : null;
152
+ if (!textType || !Array.isArray(payload.content)) {
153
+ continue;
154
+ }
155
+ const text = payload.content
156
+ .filter((item) => item?.type === textType && typeof item.text === "string")
157
+ .map((item) => item.text)
158
+ .join("\n");
159
+ if (text.trim() && !text.trim().startsWith("<")) {
160
+ messages.push({ role: payload.role, text });
161
+ }
162
+ }
163
+ return messages;
164
+ }
129
165
  function extractCodexTitle(entries) {
130
166
  for (const entry of entries) {
131
167
  if (entry?.type === "event_msg" && entry?.payload?.type === "user_message") {
@@ -11,6 +11,14 @@ export function listSessionsForBackend(backendType: any, opts?: {}): Promise<{
11
11
  cwd: string | null;
12
12
  title: string | null;
13
13
  updatedAt: number;
14
+ preview: {
15
+ firstUserMessage: string | null;
16
+ firstReply: string | null;
17
+ lastMessage: {
18
+ role: any;
19
+ text: string | null;
20
+ } | null;
21
+ };
14
22
  }[]>;
15
23
  export function resolveResumeContext(backend: any, sessionId: any, options?: {}): Promise<{
16
24
  provider: any;
@@ -11,6 +11,24 @@ export function normalizeSessionTitle(text: any, maxLength?: number): string | n
11
11
  * are skipped.
12
12
  */
13
13
  export function readJsonlHeadEntries(filePath: any, maxLines?: number, maxBytes?: number): Promise<any[]>;
14
+ /**
15
+ * Parses the tail of a jsonl file (last `maxBytes`); the first, possibly
16
+ * partial, line is dropped unless the read started at the file's beginning.
17
+ */
18
+ export function readJsonlTailEntries(filePath: any, maxBytes?: number): Promise<any[]>;
19
+ /**
20
+ * Session preview for the resume picker, from `{ role, text }` messages read
21
+ * off the head and tail of a session file: the first user message, the
22
+ * assistant text that answered it, and the session's last message.
23
+ */
24
+ export function buildSessionPreview(headMessages: any, tailMessages: any): {
25
+ firstUserMessage: string | null;
26
+ firstReply: string | null;
27
+ lastMessage: {
28
+ role: any;
29
+ text: string | null;
30
+ } | null;
31
+ };
14
32
  export function isExistingDirectory(targetPath: any): Promise<boolean>;
15
33
  export function resolveSessionRunDirectory(sessionPath: any): Promise<string>;
16
34
  export function listCandidateWorkingDirectories(options?: {}): any[];
@@ -46,7 +46,7 @@ export function normalizeSessionTitle(text, maxLength = 80) {
46
46
  * session files stay cheap to inspect). Unreadable files and unparsable lines
47
47
  * are skipped.
48
48
  */
49
- export async function readJsonlHeadEntries(filePath, maxLines = 50, maxBytes = 128 * 1024) {
49
+ export async function readJsonlHeadEntries(filePath, maxLines = 200, maxBytes = 128 * 1024) {
50
50
  const entries = [];
51
51
  const input = fs.createReadStream(filePath, { end: maxBytes - 1 });
52
52
  const rl = readline.createInterface({ input, crlfDelay: Infinity });
@@ -78,6 +78,70 @@ export async function readJsonlHeadEntries(filePath, maxLines = 50, maxBytes = 1
78
78
  }
79
79
  return entries;
80
80
  }
81
+ /**
82
+ * Parses the tail of a jsonl file (last `maxBytes`); the first, possibly
83
+ * partial, line is dropped unless the read started at the file's beginning.
84
+ */
85
+ export async function readJsonlTailEntries(filePath, maxBytes = 64 * 1024) {
86
+ let handle;
87
+ try {
88
+ handle = await fsp.open(filePath, "r");
89
+ const { size } = await handle.stat();
90
+ const start = Math.max(0, size - maxBytes);
91
+ const buffer = Buffer.alloc(size - start);
92
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
93
+ const lines = buffer.subarray(0, bytesRead).toString("utf8").split("\n");
94
+ if (start > 0) {
95
+ lines.shift();
96
+ }
97
+ const entries = [];
98
+ for (const line of lines) {
99
+ try {
100
+ if (line.trim())
101
+ entries.push(JSON.parse(line));
102
+ }
103
+ catch {
104
+ continue;
105
+ }
106
+ }
107
+ return entries;
108
+ }
109
+ catch {
110
+ return [];
111
+ }
112
+ finally {
113
+ await handle?.close();
114
+ }
115
+ }
116
+ function clipPreviewText(text, maxLength = 2000) {
117
+ const trimmed = String(text || "").trim();
118
+ if (!trimmed) {
119
+ return null;
120
+ }
121
+ return trimmed.length > maxLength ? `${trimmed.slice(0, maxLength)}…` : trimmed;
122
+ }
123
+ /**
124
+ * Session preview for the resume picker, from `{ role, text }` messages read
125
+ * off the head and tail of a session file: the first user message, the
126
+ * assistant text that answered it, and the session's last message.
127
+ */
128
+ export function buildSessionPreview(headMessages, tailMessages) {
129
+ const firstUserIndex = headMessages.findIndex((message) => message.role === "user");
130
+ const replies = [];
131
+ if (firstUserIndex >= 0) {
132
+ for (const message of headMessages.slice(firstUserIndex + 1)) {
133
+ if (message.role === "user")
134
+ break;
135
+ replies.push(message.text);
136
+ }
137
+ }
138
+ const last = tailMessages[tailMessages.length - 1] || headMessages[headMessages.length - 1] || null;
139
+ return {
140
+ firstUserMessage: firstUserIndex >= 0 ? clipPreviewText(headMessages[firstUserIndex].text) : null,
141
+ firstReply: clipPreviewText(replies.join("\n\n")),
142
+ lastMessage: last ? { role: last.role, text: clipPreviewText(last.text) } : null,
143
+ };
144
+ }
81
145
  export async function isExistingDirectory(targetPath) {
82
146
  const normalizedPath = typeof targetPath === "string" ? targetPath.trim() : "";
83
147
  if (!normalizedPath) {
@@ -46,6 +46,27 @@ function injectEnableGoalsArgs(args, enableGoals) {
46
46
  normalized.splice(insertAt, 0, "--enable", "goals");
47
47
  return normalized;
48
48
  }
49
+ /**
50
+ * Add one `-c key=value` per config override (e.g. the per-task MCP server a
51
+ * remote-worktree task needs), before `--listen` like `--enable goals`.
52
+ *
53
+ * @param {string[]} args
54
+ * @param {unknown} overrides
55
+ * @returns {string[]}
56
+ */
57
+ function injectConfigOverrideArgs(args, overrides) {
58
+ const pairs = Array.isArray(overrides)
59
+ ? overrides.filter((entry) => typeof entry === "string" && entry.includes("="))
60
+ : [];
61
+ if (pairs.length === 0) {
62
+ return args;
63
+ }
64
+ const normalized = [...args];
65
+ const listenIndex = normalized.indexOf("--listen");
66
+ const insertAt = listenIndex >= 0 ? listenIndex : normalized.length;
67
+ normalized.splice(insertAt, 0, ...pairs.flatMap((pair) => ["-c", pair]));
68
+ return normalized;
69
+ }
49
70
  function createRpcError(payload) {
50
71
  const message = String(payload?.message || payload?.error?.message || "Codex app-server request failed");
51
72
  const error = new Error(message);
@@ -78,7 +99,7 @@ export class CodexAppServerTransport extends EventEmitter {
78
99
  }
79
100
  this.command = command;
80
101
  this.enableGoals = options.enableGoals === true;
81
- this.args = injectEnableGoalsArgs(args, this.enableGoals);
102
+ this.args = injectConfigOverrideArgs(injectEnableGoalsArgs(args, this.enableGoals), options.configOverrides);
82
103
  this.env = options.env && typeof options.env === "object" ? { ...options.env } : {};
83
104
  this.ignoreCodexApiKey = options.ignoreCodexApiKey === true;
84
105
  this.child = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@love-moon/ai-sdk",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/lovemoon-ai/conductor.git"
@@ -53,12 +53,12 @@
53
53
  "zod": "^4.1.5"
54
54
  },
55
55
  "optionalDependencies": {
56
- "@love-moon/chat-web": "^0.14.0"
56
+ "@love-moon/chat-web": "^0.15.1"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@types/node": "^22.10.2",
60
60
  "tsx": "^4.20.6",
61
61
  "typescript": "^5.6.3"
62
62
  },
63
- "gitCommitId": "6def94c"
63
+ "gitCommitId": "4dd9818"
64
64
  }