@myagentroam/node 0.9.3 → 0.9.4

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,7 +1,9 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { isImageAttachment, isRunnerImageAttachment, parseComposerAttachmentIds, parseMessageAttachments, sessionTitleFromFirstMessage, withNonImageAttachmentPrompt } from '../util/node-operation-parsers.js';
2
3
  import { parseSecretEnvironment } from '../util/secret-environment.js';
3
4
  export class SessionMessageService {
4
5
  options;
6
+ pendingMessages = new Map();
5
7
  constructor(options) {
6
8
  this.options = options;
7
9
  }
@@ -19,20 +21,21 @@ export class SessionMessageService {
19
21
  typeof input.content !== 'string' ||
20
22
  input.content.length === 0)
21
23
  throw new Error('MESSAGE_INVALID');
24
+ const clientMessageId = input.clientMessageId;
25
+ const content = input.content;
22
26
  const inlineAttachments = parseMessageAttachments(input.attachments);
23
27
  const secretEnvironment = parseSecretEnvironment(input.secretEnvironment);
24
28
  const attachmentIds = parseComposerAttachmentIds(input.attachmentIds);
25
29
  if (inlineAttachments.length > 0 && attachmentIds.length > 0)
26
30
  throw new Error('MESSAGE_ATTACHMENTS_INVALID');
27
- const session = this.options.runtime.setTemporaryTitleIfMissing((await this.options.resolve(input.sessionId)).id, sessionTitleFromFirstMessage(input.content));
28
- this.options.emitSession(session);
31
+ const session = await this.options.resolve(input.sessionId);
29
32
  const intent = input.deliveryIntent;
30
33
  if (intent !== undefined &&
31
34
  intent !== 'SEND' &&
32
35
  intent !== 'QUEUE' &&
33
36
  intent !== 'REPLACE_CURRENT')
34
37
  throw new Error('MESSAGE_INVALID');
35
- const existing = this.options.runtime.findMessageRun(session.id, input.clientMessageId);
38
+ const existing = this.options.runtime.findMessageRun(session.id, clientMessageId);
36
39
  if (existing !== undefined)
37
40
  return {
38
41
  session: this.options.present(session),
@@ -40,84 +43,110 @@ export class SessionMessageService {
40
43
  delivery: this.options.runtime.isQueuedRun(existing.id) ? 'QUEUED' : 'STARTED',
41
44
  idempotent: true
42
45
  };
43
- const uploaded = await this.options.uploads.resolve(session.workspaceId, attachmentIds);
44
- const attachments = attachmentIds.length === 0
45
- ? inlineAttachments
46
- : uploaded.map((attachment) => attachment.attachment);
47
- const imageAttachments = attachments.filter(isImageAttachment);
48
- const runnerImageAttachments = attachments.filter(isRunnerImageAttachment);
49
- const active = this.options.runtime
50
- .listRunsForSession(session.id)
51
- .find((run) => run.status === 'STARTING' || run.status === 'RUNNING');
52
- const replaceCurrentRun = intent === 'REPLACE_CURRENT' ? active : undefined;
53
- const queueWasPaused = this.options.runtime.sessionQueue(session.id).paused;
54
- const queuedBeforeCreate = this.options.runtime.hasActiveSessionLease(session.id) || intent === 'QUEUE';
55
- const created = this.options.runtime.createMessageRun({
56
- sessionId: session.id,
57
- clientMessageId: input.clientMessageId,
58
- content: input.content,
59
- ...(attachments.length === 0 ? {} : { attachments })
60
- });
61
- if (!created.created)
62
- return {
63
- session: this.options.present(session),
64
- run: created.run,
65
- delivery: this.options.runtime.isQueuedRun(created.run.id) ? 'QUEUED' : 'STARTED',
66
- idempotent: true
67
- };
68
- this.options.attachments.cache(created.run.id, imageAttachments);
69
- let attachmentPaths;
46
+ const pendingKey = `${session.id}\u0000${clientMessageId}`;
47
+ const pending = this.pendingMessages.get(pendingKey);
48
+ if (pending !== undefined)
49
+ return pending;
50
+ const operation = (async () => {
51
+ const runId = randomUUID();
52
+ let enqueued = false;
53
+ try {
54
+ const uploaded = await this.options.uploads.resolve(session.workspaceId, attachmentIds);
55
+ const attachments = attachmentIds.length === 0
56
+ ? inlineAttachments
57
+ : uploaded.map((attachment) => attachment.attachment);
58
+ const imageAttachments = attachments.filter(isImageAttachment);
59
+ const runnerImageAttachments = attachments.filter(isRunnerImageAttachment);
60
+ const attachmentPaths = await this.persistAttachments(session, runId, attachments);
61
+ const runnerInput = withNonImageAttachmentPrompt(content, attachmentPaths);
62
+ const planActive = this.options.commands
63
+ .list(session.id)
64
+ .some((state) => state.commandId === 'plan');
65
+ const runner = this.options.runners.require(session.runner);
66
+ const active = this.options.runtime
67
+ .listRunsForSession(session.id)
68
+ .find((run) => run.status === 'STARTING' || run.status === 'RUNNING');
69
+ const replaceCurrentRun = intent === 'REPLACE_CURRENT' ? active : undefined;
70
+ const queueWasPaused = this.options.runtime.sessionQueue(session.id).paused;
71
+ const queuedBeforeCreate = this.options.runtime.hasActiveSessionLease(session.id) || intent === 'QUEUE';
72
+ const created = this.options.queue.enqueue({
73
+ runId,
74
+ sessionId: session.id,
75
+ workspaceId: session.workspaceId,
76
+ runner: session.runner,
77
+ input: runner.prepareMessageInput(runnerInput, planActive ? 'plan' : 'default'),
78
+ attachments: runnerImageAttachments,
79
+ attachmentPaths,
80
+ cwd: session.cwd,
81
+ externalSessionId: session.externalSessionId,
82
+ model: session.model,
83
+ effort: session.effort,
84
+ access: session.access,
85
+ collaborationMode: planActive ? 'plan' : 'default',
86
+ secretEnvironment,
87
+ ...(runner.serviceTier() === 'fast' ? { serviceTier: 'fast' } : {})
88
+ }, {
89
+ runId,
90
+ sessionId: session.id,
91
+ clientMessageId,
92
+ content: runnerInput,
93
+ ...(Number.isSafeInteger(input.__requestUserId)
94
+ ? { initiatedByUserId: input.__requestUserId }
95
+ : {}),
96
+ ...(attachments.length === 0 ? {} : { attachments })
97
+ });
98
+ enqueued = created.created;
99
+ if (!created.created) {
100
+ this.options.attachments.cleanup(runId);
101
+ return {
102
+ session: this.options.present(session),
103
+ run: created.run,
104
+ delivery: this.options.runtime.isQueuedRun(created.run.id) ? 'QUEUED' : 'STARTED',
105
+ idempotent: true
106
+ };
107
+ }
108
+ const acceptedSession = this.options.runtime.setTemporaryTitleIfMissing(session.id, sessionTitleFromFirstMessage(content));
109
+ this.options.emitSession(acceptedSession);
110
+ this.options.attachments.cache(created.run.id, imageAttachments);
111
+ void this.options.uploads.consume(attachmentIds).catch(() => undefined);
112
+ if (queueWasPaused && active === undefined && intent !== 'QUEUE')
113
+ this.options.runtime.prioritizeQueuedRun(created.run.id);
114
+ if (!queueWasPaused || intent !== 'QUEUE')
115
+ this.options.runtime.resumeSessionQueue(session.id);
116
+ if (replaceCurrentRun !== undefined)
117
+ this.options.queue.replace(created.run.id, replaceCurrentRun.id);
118
+ else if (intent !== 'QUEUE' ||
119
+ !queuedBeforeCreate ||
120
+ !this.options.runtime.hasActiveSessionLease(session.id))
121
+ this.options.queue.schedule(session.id);
122
+ this.options.emitQueue(session.workspaceId, session.id);
123
+ return {
124
+ session: this.options.present(acceptedSession),
125
+ run: created.run,
126
+ delivery: replaceCurrentRun !== undefined
127
+ ? 'RESTARTING'
128
+ : queuedBeforeCreate
129
+ ? 'QUEUED'
130
+ : 'STARTED',
131
+ idempotent: false
132
+ };
133
+ }
134
+ catch (error) {
135
+ if (!enqueued) {
136
+ this.options.attachments.clear(runId);
137
+ this.options.attachments.cleanup(runId);
138
+ }
139
+ throw error;
140
+ }
141
+ })();
142
+ this.pendingMessages.set(pendingKey, operation);
70
143
  try {
71
- attachmentPaths = await this.persistAttachments(session, created.run.id, attachments);
72
- await this.options.uploads.consume(attachmentIds);
144
+ return await operation;
73
145
  }
74
- catch (error) {
75
- this.options.runtime.deleteQueuedRun(created.run.id);
76
- this.options.attachments.clear(created.run.id);
77
- this.options.attachments.cleanup(created.run.id);
78
- throw error;
146
+ finally {
147
+ if (this.pendingMessages.get(pendingKey) === operation)
148
+ this.pendingMessages.delete(pendingKey);
79
149
  }
80
- const runnerInput = withNonImageAttachmentPrompt(input.content, attachmentPaths);
81
- this.options.runtime.updateQueuedMessage(created.run.id, runnerInput);
82
- const planActive = this.options.commands
83
- .list(session.id)
84
- .some((state) => state.commandId === 'plan');
85
- this.options.queue.remember({
86
- runId: created.run.id,
87
- sessionId: created.run.sessionId,
88
- workspaceId: created.run.workspaceId,
89
- runner: created.run.runner,
90
- input: this.options.runners
91
- .require(created.run.runner)
92
- .prepareMessageInput(runnerInput, planActive ? 'plan' : 'default'),
93
- attachments: runnerImageAttachments,
94
- attachmentPaths,
95
- cwd: session.cwd,
96
- externalSessionId: session.externalSessionId,
97
- model: session.model,
98
- effort: session.effort,
99
- access: session.access,
100
- collaborationMode: planActive ? 'plan' : 'default',
101
- secretEnvironment,
102
- ...(this.options.runners.require(created.run.runner).serviceTier() === 'fast'
103
- ? { serviceTier: 'fast' }
104
- : {})
105
- });
106
- if (queueWasPaused && active === undefined && intent !== 'QUEUE')
107
- this.options.runtime.prioritizeQueuedRun(created.run.id);
108
- if (!queueWasPaused || intent !== 'QUEUE')
109
- this.options.runtime.resumeSessionQueue(session.id);
110
- this.options.emitQueue(session.workspaceId, session.id);
111
- if (replaceCurrentRun !== undefined)
112
- this.options.queue.replace(created.run.id, replaceCurrentRun.id);
113
- else if (intent !== 'QUEUE' || !queuedBeforeCreate)
114
- this.options.queue.schedule(session.id);
115
- return {
116
- session: this.options.present(session),
117
- run: created.run,
118
- delivery: replaceCurrentRun !== undefined ? 'RESTARTING' : queuedBeforeCreate ? 'QUEUED' : 'STARTED',
119
- idempotent: false
120
- };
121
150
  }
122
151
  async persistAttachments(session, runId, attachments) {
123
152
  return this.options.attachments.persistFiles(session.cwd, runId, attachments.filter((attachment) => !isRunnerImageAttachment(attachment)));
@@ -60,7 +60,7 @@ export class WorkspaceChangeService {
60
60
  additions: null,
61
61
  deletions: null,
62
62
  binary: false,
63
- diffAvailable: change.state !== 'UNTRACKED'
63
+ diffAvailable: true
64
64
  });
65
65
  const value = {
66
66
  branch: root.branch,
@@ -1,7 +1,7 @@
1
1
  import type { NodeOperationHandler } from '../connector/node-operation-router.js';
2
2
  import type { NodeRuntimeState } from '../runtime-state.js';
3
3
  import type { RunnerName } from '@myagentroam/protocol';
4
- import type { NodeMcpInstallation } from '../database.js';
4
+ import type { NodeAgentRun, NodeMcpInstallation, NodeMessageAttachmentInput } from '../database.js';
5
5
  export interface QueuedRunStart<TAttachment = unknown> {
6
6
  readonly runId: string;
7
7
  readonly sessionId: string;
@@ -36,7 +36,17 @@ export declare class WorkspaceQueueWorkbenchService<TAttachment> {
36
36
  private readonly options;
37
37
  private readonly starts;
38
38
  constructor(options: WorkspaceQueueWorkbenchServiceOptions);
39
- remember(start: QueuedRunStart<TAttachment>): void;
39
+ enqueue(start: QueuedRunStart<TAttachment>, message: {
40
+ readonly runId: string;
41
+ readonly sessionId: string;
42
+ readonly clientMessageId: string;
43
+ readonly content: string;
44
+ readonly initiatedByUserId?: number;
45
+ readonly attachments?: readonly NodeMessageAttachmentInput[];
46
+ }): {
47
+ readonly run: NodeAgentRun;
48
+ readonly created: boolean;
49
+ };
40
50
  pending(runId: string): QueuedRunStart<TAttachment> | undefined;
41
51
  remove(runId: string): void;
42
52
  clear(): void;
@@ -8,8 +8,13 @@ export class WorkspaceQueueWorkbenchService {
8
8
  constructor(options) {
9
9
  this.options = options;
10
10
  }
11
- remember(start) {
12
- this.starts.set(start.runId, start);
11
+ enqueue(start, message) {
12
+ if (message.runId !== start.runId || message.sessionId !== start.sessionId)
13
+ throw new Error('QUEUE_RUN_MISMATCH');
14
+ const created = this.options.runtime.createMessageRun(message);
15
+ if (created.created)
16
+ this.starts.set(start.runId, start);
17
+ return created;
13
18
  }
14
19
  pending(runId) {
15
20
  return this.starts.get(runId);
@@ -15,6 +15,7 @@ export interface ConversationHistoryPage {
15
15
  readonly source: ConversationHistorySource;
16
16
  readonly readAt: number;
17
17
  readonly latestVisibleAt: number;
18
+ readonly deferredOlderHistory?: boolean;
18
19
  }
19
20
  export declare function extractId(result: unknown, key: 'thread' | 'turn'): string;
20
21
  export declare function settleWithin<T>(promise: Promise<T>, milliseconds: number): Promise<T | undefined>;
@@ -50,7 +51,7 @@ export declare function nativeConversationPage(session: NodeAgentSession, histor
50
51
  */
51
52
  export declare function nativeUserClientMessageId(entry: Extract<NativeSessionHistory['items'][number], {
52
53
  readonly kind: 'message';
53
- }>, createdAt: number, runtimeTurns: readonly NodeConversationTurn[]): string | null;
54
+ }>, createdAt: number | null, runtimeTurns: readonly NodeConversationTurn[]): string | null;
54
55
  /**
55
56
  * Normalize the documented App Server `thread/read(includeTurns: true)`
56
57
  * result. Only items with a user-visible Workbench representation are kept;
@@ -165,14 +165,18 @@ export function nativeConversationPage(session, history, input, runtimeTurns = [
165
165
  .slice(start, end)
166
166
  .map((entries, relativeIndex) => {
167
167
  const firstIndex = entries[0]?.index ?? 0;
168
- const turnId = `${session.id}:native:${firstIndex}`;
169
- const nativeItems = entries.map(({ entry, index }) => nativeTranscriptConversationItem(session, entry, index, turnId, runtimeTurns, registerImages));
170
- const clientMessageId = nativeItems.flatMap((item) => item.kind === 'user_message' && isPlainRecord(item.payload)
171
- ? [item.payload.clientMessageId]
172
- : [])[0];
173
- const runtimeTurn = typeof clientMessageId === 'string'
174
- ? runtimeTurns.find((turn) => runtimeTurnClientMessageId(turn) === clientMessageId)
168
+ const nativeUser = entries.find(({ entry }) => entry.kind === 'message' && entry.role === 'USER')?.entry;
169
+ const matchedClientMessageId = nativeUser?.kind === 'message'
170
+ ? nativeUserClientMessageId(nativeUser, nativeUser.createdAt, runtimeTurns)
171
+ : null;
172
+ const runtimeTurn = typeof matchedClientMessageId === 'string'
173
+ ? runtimeTurns.find((turn) => runtimeTurnClientMessageId(turn) === matchedClientMessageId)
175
174
  : undefined;
175
+ const turnId = runtimeTurn?.id ?? `${session.id}:native:${firstIndex}`;
176
+ const nativeItems = entries.map(({ entry, index }) => ({
177
+ ...nativeTranscriptConversationItem(session, entry, index, turnId, runtimeTurns, registerImages),
178
+ runId: runtimeTurn?.runId ?? null
179
+ }));
176
180
  const runtimeErrors = (runtimeTurn?.items ?? [])
177
181
  .filter((item) => item.kind === 'error')
178
182
  .map((item) => ({ ...item, turnId, runId: runtimeTurn?.runId ?? null }));
@@ -180,7 +184,7 @@ export function nativeConversationPage(session, history, input, runtimeTurns = [
180
184
  ...nativeItems,
181
185
  ...runtimeErrors.filter((error) => !nativeItems.some((item) => item.id === error.id))
182
186
  ];
183
- const startedAt = items[0]?.startedAt ?? firstIndex;
187
+ const startedAt = items[0]?.startedAt ?? null;
184
188
  const completedAt = items.at(-1)?.completedAt ?? startedAt;
185
189
  const after = lastNativeItemId(entries);
186
190
  return {
@@ -205,7 +209,7 @@ export function nativeConversationPage(session, history, input, runtimeTurns = [
205
209
  completedAt
206
210
  };
207
211
  });
208
- const combined = [...nativePageTurns, ...missingRuntimeErrorTurns].sort((left, right) => (left.startedAt ?? 0) - (right.startedAt ?? 0));
212
+ const combined = [...nativePageTurns, ...missingRuntimeErrorTurns];
209
213
  const dropped = combined.slice(0, Math.max(0, combined.length - limit));
210
214
  const turns = combined.slice(-limit);
211
215
  const nativePageIds = new Set(nativePageTurns.map((turn) => turn.id));
@@ -241,7 +245,7 @@ function nativeTranscriptTurns(items) {
241
245
  return turns;
242
246
  }
243
247
  function nativeTranscriptConversationItem(session, entry, index, turnId, runtimeTurns, registerImages) {
244
- const time = entry.createdAt ?? index;
248
+ const time = entry.createdAt;
245
249
  const isToolCall = entry.kind === 'tool_call';
246
250
  const isUnknown = entry.kind === 'unknown';
247
251
  const isReasoning = entry.kind === 'reasoning_summary';
@@ -336,6 +340,7 @@ function nativeTranscriptConversationItem(session, entry, index, turnId, runtime
336
340
  * runtime user messages by content and creation time so native history replaces the optimistic Turn.
337
341
  */
338
342
  export function nativeUserClientMessageId(entry, createdAt, runtimeTurns) {
343
+ const candidates = [];
339
344
  for (const turn of runtimeTurns) {
340
345
  const user = turn.items.find((item) => item.kind === 'user_message');
341
346
  if (user === undefined || !isPlainRecord(user.payload))
@@ -346,13 +351,19 @@ export function nativeUserClientMessageId(entry, createdAt, runtimeTurns) {
346
351
  if (clientMessageId === null || payload.text !== stripClaudePlanTag(entry.text))
347
352
  continue;
348
353
  const runtimeCreatedAt = user.startedAt ?? turn.startedAt;
349
- if (runtimeCreatedAt !== null &&
350
- runtimeCreatedAt !== undefined &&
351
- Math.abs(createdAt - runtimeCreatedAt) > 60_000)
352
- continue;
353
- return clientMessageId;
354
+ candidates.push({ clientMessageId, createdAt: runtimeCreatedAt ?? null });
354
355
  }
355
- return null;
356
+ if (createdAt === null)
357
+ return candidates.length === 1 ? candidates[0].clientMessageId : null;
358
+ const timed = candidates
359
+ .filter((candidate) => candidate.createdAt !== null)
360
+ .map((candidate) => ({
361
+ ...candidate,
362
+ distance: Math.abs(createdAt - candidate.createdAt)
363
+ }))
364
+ .filter((candidate) => candidate.distance <= 60_000)
365
+ .sort((left, right) => left.distance - right.distance);
366
+ return timed[0]?.clientMessageId ?? null;
356
367
  }
357
368
  /**
358
369
  * Normalize the documented App Server `thread/read(includeTurns: true)`
package/dist/workspace.js CHANGED
@@ -1232,14 +1232,7 @@ export async function readCurrentChanges(workspacePath) {
1232
1232
  export async function readCurrentChangeDiff(workspacePath, requestedPath) {
1233
1233
  const path = safeGitWorkspacePath(requestedPath);
1234
1234
  await assertCurrentChangePathAccessible(workspacePath, path);
1235
- const result = await runGit(workspacePath, [
1236
- 'diff',
1237
- '--no-ext-diff',
1238
- '--no-textconv',
1239
- 'HEAD',
1240
- '--',
1241
- `:(literal)${path}`
1242
- ]);
1235
+ const result = await readCurrentChangeGitDiff(workspacePath, path);
1243
1236
  const after = await readWorkingTreeText(workspacePath, path);
1244
1237
  const binary = result.output.includes(0) || result.text.includes('Binary files ') || after?.binary === true;
1245
1238
  if (binary) {
@@ -1263,6 +1256,38 @@ export async function readCurrentChangeDiff(workspacePath, requestedPath) {
1263
1256
  ...(after === undefined ? {} : { afterTruncated: after.truncated })
1264
1257
  };
1265
1258
  }
1259
+ async function readCurrentChangeGitDiff(workspacePath, path) {
1260
+ const untracked = await runGit(workspacePath, [
1261
+ 'ls-files',
1262
+ '--others',
1263
+ '--exclude-standard',
1264
+ '-z',
1265
+ '--',
1266
+ `:(literal)${path}`
1267
+ ]);
1268
+ if (untracked.text.split('\0').includes(path)) {
1269
+ const result = await runGitAllowFailure(workspacePath, [
1270
+ 'diff',
1271
+ '--no-index',
1272
+ '--no-ext-diff',
1273
+ '--no-textconv',
1274
+ '--',
1275
+ '/dev/null',
1276
+ path
1277
+ ]);
1278
+ if (result.code !== 0 && result.code !== 1)
1279
+ throw new GitCommandError(result.code, result.stderr);
1280
+ return result;
1281
+ }
1282
+ return runGit(workspacePath, [
1283
+ 'diff',
1284
+ '--no-ext-diff',
1285
+ '--no-textconv',
1286
+ 'HEAD',
1287
+ '--',
1288
+ `:(literal)${path}`
1289
+ ]);
1290
+ }
1266
1291
  /** Current Changes must not expose an external link. */
1267
1292
  export async function isWorkspaceChangeVisible(workspacePath, requestedPath) {
1268
1293
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "description": "MyAgentRoam Node runtime CLI.",
5
5
  "type": "module",
6
6
  "files": [
@@ -24,7 +24,7 @@
24
24
  "node-pty": "1.1.0",
25
25
  "ws": "^8.21.3",
26
26
  "zod": "4.4.3",
27
- "@myagentroam/protocol": "^0.9.3"
27
+ "@myagentroam/protocol": "^0.9.4"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/ws": "^8.18.1"