@dinoxx/dinox-cli 2.4.0 → 2.5.2

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.
@@ -105,20 +105,78 @@ export declare function withdrawQueuedRun(db: AbstractPowerSyncDatabase, input:
105
105
  }): Promise<ChatUIMessage>;
106
106
  /** Runs left non-terminal by a previous process can never finish; mark them interrupted. */
107
107
  export declare function interruptOrphanedRuns(db: Tx, userId: string): Promise<number>;
108
- /** Agent session bound to a thread, scoped to the thread's current topic. */
108
+ /**
109
+ * One gateway's agent session on a thread, scoped to the thread's topic when it
110
+ * started. The thread can switch agents, so every gateway keeps its own session
111
+ * and remembers the last transcript message it saw.
112
+ */
109
113
  export type GatewaySessionBinding = {
110
114
  sessionId: string;
111
115
  topicId: string | null;
112
- gatewayId: string;
116
+ /** Newest transcript message the session has seen; null when it has seen nothing of the topic yet. */
117
+ lastSeenMessageId: string | null;
118
+ /** Directory the session works in; absent for bindings written before it was recorded. */
119
+ cwd?: string;
120
+ /** Written before seen messages were tracked: the session is assumed to be up to date. */
121
+ legacy?: true;
113
122
  };
114
- export declare function readSessionBinding(db: Tx, threadId: string): Promise<GatewaySessionBinding | null>;
115
- export declare function writeSessionBinding(db: Tx, threadId: string, binding: GatewaySessionBinding): Promise<void>;
123
+ export declare function readSessionBinding(db: Tx, threadId: string, gatewayId: string): Promise<GatewaySessionBinding | null>;
124
+ /** Replaces this gateway's binding and keeps every other gateway's. */
125
+ export declare function writeSessionBinding(db: AbstractPowerSyncDatabase, threadId: string, gatewayId: string, binding: GatewaySessionBinding): Promise<void>;
126
+ /** Directories this gateway's sessions worked in, most recently used first. */
127
+ export declare function readRecentSessionFolders(db: Tx, gatewayId: string, limit?: number): Promise<string[]>;
128
+ /** The instructions the user wrote for the thread's agent (`c_project.agent_md`); empty when none. */
129
+ export declare function readAgentInstructions(db: Tx, threadId: string): Promise<string>;
116
130
  /**
117
131
  * The thread's current topic: the latest "new topic" marker the app wrote
118
132
  * (`entry_type = 'internal'`, `meta_data.kind = 'context_reset'`). A new topic
119
- * starts a fresh agent session.
133
+ * starts a fresh agent session; like the app, it covers rows written after the marker.
134
+ */
135
+ export declare function readCurrentTopic(db: Tx, threadId: string): Promise<{
136
+ id: string;
137
+ createdAt: string | null;
138
+ } | null>;
139
+ /** Marks a reply with the agent session that wrote it (`metadata.gateway`). */
140
+ export declare function withGatewaySession(message: ChatUIMessage, gateway: {
141
+ gatewayId: string;
142
+ sessionId: string;
143
+ }): ChatUIMessage;
144
+ /**
145
+ * Waits until a message exists in the local replica (deleted or not), e.g. the
146
+ * last message the client saw before sending a turn. False when it has not
147
+ * synced within the timeout or the wait was aborted.
120
148
  */
121
- export declare function readCurrentTopicId(db: Tx, threadId: string): Promise<string | null>;
149
+ export declare function waitForMessage(db: Tx, messageId: string, options: {
150
+ timeoutMs: number;
151
+ signal?: AbortSignal;
152
+ }): Promise<boolean>;
153
+ /** A transcript message reduced to what another agent needs: who said what. */
154
+ export type TranscriptMessage = {
155
+ id: string;
156
+ role: 'user' | 'assistant';
157
+ /** The answering agent's name; null for the user or an unknown agent. */
158
+ speaker: string | null;
159
+ text: string;
160
+ createdAt: string | null;
161
+ };
162
+ /**
163
+ * The newest transcript messages in a window, oldest first, with how many
164
+ * matching messages lie before the page. Bounds are exclusive: `since` is a
165
+ * topic start (by `created_at`, as the app scopes topics), `afterMessageId` and
166
+ * `beforeMessageId` are transcript positions; an id that no longer exists is no bound.
167
+ */
168
+ export declare function readTranscriptPage(db: Tx, input: {
169
+ threadId: string;
170
+ since?: string | null;
171
+ afterMessageId?: string | null;
172
+ beforeMessageId?: string | null;
173
+ /** Leaves out the turns this agent session answered itself (its replies and their prompts). */
174
+ excludeSessionId?: string | null;
175
+ limit: number;
176
+ }): Promise<{
177
+ messages: TranscriptMessage[];
178
+ earlier: number;
179
+ }>;
122
180
  /** File parts resolve only to resources this user owns with an https storage URL. */
123
181
  export declare function resolveTrustedResource(db: Tx, input: {
124
182
  resourceId: string;
@@ -141,6 +199,13 @@ export type RobotRegistration = {
141
199
  /** Marks the robot as hosted by this installation, for reconciliation. */
142
200
  machineId: string;
143
201
  agentId?: string;
202
+ /** Owner-facing settings, synced so the app can show them while the computer is offline. */
203
+ settings?: RobotSettings;
204
+ };
205
+ export type RobotSettings = {
206
+ approval: 'ask' | 'auto_read';
207
+ paused: boolean;
208
+ dinoxTools: boolean;
144
209
  };
145
210
  export declare function upsertRobot(db: Tx, input: RobotRegistration): Promise<void>;
146
211
  /**
@@ -319,38 +319,199 @@ export async function interruptOrphanedRuns(db, userId) {
319
319
  }
320
320
  return rows.length;
321
321
  }
322
- export async function readSessionBinding(db, threadId) {
323
- const row = await db.getOptional('SELECT robot_session_id FROM c_thread WHERE id = ? LIMIT 1', [threadId]);
324
- const parsed = parseJson(row?.robot_session_id);
325
- if (parsed?.v !== 1 || typeof parsed.sessionId !== 'string' || typeof parsed.gatewayId !== 'string')
322
+ function isRecord(value) {
323
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
324
+ }
325
+ function toBinding(value, legacy = false) {
326
+ if (!isRecord(value) || typeof value.sessionId !== 'string' || !value.sessionId)
326
327
  return null;
327
328
  return {
328
- sessionId: parsed.sessionId,
329
- topicId: typeof parsed.topicId === 'string' ? parsed.topicId : null,
330
- gatewayId: parsed.gatewayId,
329
+ sessionId: value.sessionId,
330
+ topicId: typeof value.topicId === 'string' ? value.topicId : null,
331
+ lastSeenMessageId: typeof value.lastSeenMessageId === 'string' ? value.lastSeenMessageId : null,
332
+ ...(typeof value.cwd === 'string' && value.cwd ? { cwd: value.cwd } : {}),
333
+ ...(legacy ? { legacy: true } : {}),
331
334
  };
332
335
  }
333
- export async function writeSessionBinding(db, threadId, binding) {
334
- await db.execute('UPDATE c_thread SET robot_session_id = ?, updated_at = ? WHERE id = ?', [
335
- safeJson({ v: 1, ...binding }),
336
- new Date().toISOString(),
337
- threadId,
338
- ]);
336
+ /** `c_thread.robot_session_id`: v2 keeps a binding per gateway; v1 held a single one. */
337
+ function parseBindings(raw) {
338
+ const parsed = parseJson(raw);
339
+ const bindings = {};
340
+ if (parsed?.v === 1 && typeof parsed.gatewayId === 'string') {
341
+ const binding = toBinding(parsed, true);
342
+ if (binding)
343
+ bindings[parsed.gatewayId] = binding;
344
+ }
345
+ else if (parsed?.v === 2 && isRecord(parsed.sessions)) {
346
+ for (const [gatewayId, value] of Object.entries(parsed.sessions)) {
347
+ const binding = toBinding(value);
348
+ if (binding)
349
+ bindings[gatewayId] = binding;
350
+ }
351
+ }
352
+ return bindings;
353
+ }
354
+ async function readBindings(db, threadId) {
355
+ const row = await db.getOptional('SELECT robot_session_id FROM c_thread WHERE id = ? LIMIT 1', [threadId]);
356
+ return parseBindings(row?.robot_session_id);
357
+ }
358
+ export async function readSessionBinding(db, threadId, gatewayId) {
359
+ return (await readBindings(db, threadId))[gatewayId] ?? null;
360
+ }
361
+ /** Replaces this gateway's binding and keeps every other gateway's. */
362
+ export async function writeSessionBinding(db, threadId, gatewayId, binding) {
363
+ const { legacy: _legacy, ...stored } = binding;
364
+ await withPowerSyncWriteTransaction(db, async (tx) => {
365
+ const sessions = { ...(await readBindings(tx, threadId)), [gatewayId]: stored };
366
+ await tx.execute('UPDATE c_thread SET robot_session_id = ?, updated_at = ? WHERE id = ?', [
367
+ safeJson({ v: 2, sessions }),
368
+ new Date().toISOString(),
369
+ threadId,
370
+ ]);
371
+ });
372
+ }
373
+ /** Directories this gateway's sessions worked in, most recently used first. */
374
+ export async function readRecentSessionFolders(db, gatewayId, limit = 8) {
375
+ const rows = await db.getAll(`SELECT robot_session_id FROM c_thread
376
+ WHERE robot_session_id LIKE ? AND (is_del IS NULL OR is_del = 0)
377
+ ORDER BY updated_at DESC LIMIT 200`, [`%${gatewayId}%`]);
378
+ const folders = [];
379
+ for (const row of rows) {
380
+ const cwd = parseBindings(row.robot_session_id)[gatewayId]?.cwd;
381
+ if (cwd && !folders.includes(cwd))
382
+ folders.push(cwd);
383
+ if (folders.length >= limit)
384
+ break;
385
+ }
386
+ return folders;
387
+ }
388
+ /** The instructions the user wrote for the thread's agent (`c_project.agent_md`); empty when none. */
389
+ export async function readAgentInstructions(db, threadId) {
390
+ const row = await db.getOptional(`SELECT p.agent_md FROM c_thread t JOIN c_project p ON p.id = t.project_id
391
+ WHERE t.id = ? AND (p.is_del IS NULL OR p.is_del = 0) LIMIT 1`, [threadId]);
392
+ return row?.agent_md?.trim() ?? '';
339
393
  }
340
394
  /**
341
395
  * The thread's current topic: the latest "new topic" marker the app wrote
342
396
  * (`entry_type = 'internal'`, `meta_data.kind = 'context_reset'`). A new topic
343
- * starts a fresh agent session.
397
+ * starts a fresh agent session; like the app, it covers rows written after the marker.
344
398
  */
345
- export async function readCurrentTopicId(db, threadId) {
346
- const rows = await db.getAll(`SELECT id, meta_data FROM c_chat_message
399
+ export async function readCurrentTopic(db, threadId) {
400
+ // Other markers (e.g. agent switches) share the entry type, so filter by kind in SQL.
401
+ const row = await db.getOptional(`SELECT id, created_at FROM c_chat_message
347
402
  WHERE type = ? AND source_id = ? AND entry_type = ? AND deleted_at IS NULL
348
- ORDER BY julianday(created_at) DESC, id DESC LIMIT 20`, [THREAD_CHAT_TYPE, threadId, CONTEXT_RESET_ENTRY_TYPE]);
349
- for (const row of rows) {
350
- if (parseJson(row.meta_data)?.kind === 'context_reset')
351
- return row.id;
403
+ AND json_extract(meta_data, '$.kind') = 'context_reset'
404
+ ORDER BY julianday(created_at) DESC, id DESC LIMIT 1`, [THREAD_CHAT_TYPE, threadId, CONTEXT_RESET_ENTRY_TYPE]);
405
+ return row ? { id: row.id, createdAt: row.created_at } : null;
406
+ }
407
+ /** Marks a reply with the agent session that wrote it (`metadata.gateway`). */
408
+ export function withGatewaySession(message, gateway) {
409
+ return {
410
+ ...message,
411
+ metadata: { ...(message.metadata ?? {}), gateway: { id: gateway.gatewayId, sessionId: gateway.sessionId } },
412
+ };
413
+ }
414
+ const TAIL_POLL_INTERVAL_MS = 250;
415
+ /**
416
+ * Waits until a message exists in the local replica (deleted or not), e.g. the
417
+ * last message the client saw before sending a turn. False when it has not
418
+ * synced within the timeout or the wait was aborted.
419
+ */
420
+ export async function waitForMessage(db, messageId, options) {
421
+ const deadline = Date.now() + options.timeoutMs;
422
+ for (;;) {
423
+ const row = await db.getOptional('SELECT id FROM c_chat_message WHERE id = ? LIMIT 1', [messageId]);
424
+ if (row)
425
+ return true;
426
+ if (options.signal?.aborted || Date.now() >= deadline)
427
+ return false;
428
+ await new Promise((resolve) => setTimeout(resolve, TAIL_POLL_INTERVAL_MS));
429
+ }
430
+ }
431
+ function messageText(message) {
432
+ const lines = [];
433
+ for (const part of message?.parts ?? []) {
434
+ if (part.type === 'text' && typeof part.text === 'string' && part.text.trim())
435
+ lines.push(part.text.trim());
436
+ if (part.type === 'file') {
437
+ const name = part.filename;
438
+ lines.push(`[attachment: ${typeof name === 'string' && name ? name : String(part.mediaType ?? 'file')}]`);
439
+ }
352
440
  }
353
- return null;
441
+ return lines.join('\n\n');
442
+ }
443
+ /**
444
+ * The newest transcript messages in a window, oldest first, with how many
445
+ * matching messages lie before the page. Bounds are exclusive: `since` is a
446
+ * topic start (by `created_at`, as the app scopes topics), `afterMessageId` and
447
+ * `beforeMessageId` are transcript positions; an id that no longer exists is no bound.
448
+ */
449
+ export async function readTranscriptPage(db, input) {
450
+ const since = input.since ?? null;
451
+ const after = input.afterMessageId ?? null;
452
+ const before = input.beforeMessageId ?? null;
453
+ const ownSession = input.excludeSessionId ?? null;
454
+ const where = `type = ? AND source_id = ? AND deleted_at IS NULL AND ${MESSAGE_ENTRY_FILTER}
455
+ AND role IN ('user', 'assistant')
456
+ AND (? IS NULL OR julianday(created_at) > julianday(?))
457
+ AND order_index > COALESCE((SELECT order_index FROM c_chat_message WHERE id = ?), order_index - 1)
458
+ AND order_index < COALESCE((SELECT order_index FROM c_chat_message WHERE id = ?), order_index + 1)
459
+ AND (? IS NULL OR NOT (
460
+ COALESCE(json_extract(content, '$.metadata.gateway.sessionId'), '') = ?
461
+ OR id IN (SELECT parent_id FROM c_chat_message
462
+ WHERE type = ? AND source_id = ? AND parent_id IS NOT NULL
463
+ AND json_extract(content, '$.metadata.gateway.sessionId') = ?)
464
+ ))`;
465
+ const params = [
466
+ THREAD_CHAT_TYPE,
467
+ input.threadId,
468
+ MESSAGE_ENTRY_TYPE,
469
+ LEGACY_MESSAGE_ENTRY_TYPE,
470
+ since,
471
+ since,
472
+ after,
473
+ before,
474
+ ownSession,
475
+ ownSession,
476
+ THREAD_CHAT_TYPE,
477
+ input.threadId,
478
+ ownSession,
479
+ ];
480
+ const [rows, total] = await Promise.all([
481
+ db.getAll(`SELECT id, role, content, responser_id, created_at FROM c_chat_message WHERE ${where}
482
+ ORDER BY order_index DESC, created_at DESC, id DESC LIMIT ?`, [...params, input.limit]),
483
+ db.getOptional(`SELECT COUNT(*) AS count FROM c_chat_message WHERE ${where}`, params),
484
+ ]);
485
+ const parsed = rows.reverse().map((row) => {
486
+ const message = parseJson(row.content);
487
+ const responserId = message?.metadata?.responserId;
488
+ return {
489
+ row,
490
+ responserId: row.role === 'assistant' ? (typeof responserId === 'string' && responserId) || row.responser_id || null : null,
491
+ text: messageText(message),
492
+ };
493
+ });
494
+ const names = await readAgentNames(db, parsed.map((item) => item.responserId).filter((id) => Boolean(id)));
495
+ return {
496
+ messages: parsed
497
+ .filter((item) => item.text)
498
+ .map(({ row, responserId, text }) => ({
499
+ id: row.id,
500
+ role: row.role,
501
+ speaker: responserId ? (names.get(responserId) ?? null) : null,
502
+ text,
503
+ createdAt: row.created_at,
504
+ })),
505
+ earlier: Math.max(0, Number(total?.count ?? 0) - rows.length),
506
+ };
507
+ }
508
+ /** Agent (`c_project`) display names by id; unnamed agents are left out. */
509
+ async function readAgentNames(db, ids) {
510
+ const unique = [...new Set(ids)];
511
+ if (unique.length === 0)
512
+ return new Map();
513
+ const rows = await db.getAll(`SELECT id, name FROM c_project WHERE id IN (${unique.map(() => '?').join(', ')})`, unique);
514
+ return new Map(rows.filter((row) => row.name?.trim()).map((row) => [row.id, row.name.trim()]));
354
515
  }
355
516
  /** File parts resolve only to resources this user owns with an https storage URL. */
356
517
  export async function resolveTrustedResource(db, input) {
@@ -361,7 +522,13 @@ export async function resolveTrustedResource(db, input) {
361
522
  return { url: row.storage_url, name: row.name ?? 'attachment', mediaType: row.mime_type };
362
523
  }
363
524
  function robotMetaData(input) {
364
- return JSON.stringify({ gateway: { machineId: input.machineId, ...(input.agentId ? { agentId: input.agentId } : {}) } });
525
+ return JSON.stringify({
526
+ gateway: {
527
+ machineId: input.machineId,
528
+ ...(input.agentId ? { agentId: input.agentId } : {}),
529
+ ...(input.settings ? { settings: input.settings } : {}),
530
+ },
531
+ });
365
532
  }
366
533
  export async function upsertRobot(db, input) {
367
534
  const now = new Date().toISOString();
@@ -3,10 +3,16 @@
3
3
  * Claude Code, Codex, ... over ACP) as remote devices the Dinox app can drive.
4
4
  *
5
5
  * The app sees each gateway as one `dinox.remote-device.v2` device. The agent
6
- * owns its conversation context (gateway-session mode): each turn sends only
7
- * the new user message, while Dinox keeps a display/sync copy of the transcript.
6
+ * owns its conversation context (gateway-session mode): each turn sends the new
7
+ * user message plus any transcript messages its session has not seen (another
8
+ * agent answered meanwhile), while Dinox keeps the synced copy of the transcript.
8
9
  */
9
10
  export type GatewayKind = 'acp' | 'echo';
11
+ /**
12
+ * When the agent must ask before acting: `ask` every time, or `auto_read`
13
+ * (reading and searching run without asking; edits and commands still ask).
14
+ */
15
+ export type GatewayApprovalMode = 'ask' | 'auto_read';
10
16
  /** Local-only gateway definition (never synced: it may carry commands and env). */
11
17
  export type GatewayDefinition = {
12
18
  /** Stable id; also the `c_robot.id` the app binds agents to. */
@@ -20,13 +26,22 @@ export type GatewayDefinition = {
20
26
  /** ACP: executable started over stdio, e.g. `hermes` with args `["acp"]`. */
21
27
  command?: string;
22
28
  args?: string[];
23
- /** Working directory handed to the agent's sessions. */
29
+ /** Working directory for new agent sessions (each session keeps the one it started in). */
24
30
  cwd?: string;
31
+ /**
32
+ * Directory of sessions bound before sessions recorded their own: the
33
+ * default directory as it was when it was first changed.
34
+ */
35
+ previousCwd?: string;
25
36
  /** Extra environment for the agent process (secrets stay on this machine). */
26
37
  env?: Record<string, string>;
27
38
  enabled: boolean;
28
39
  /** Offer the read-only Dinox knowledge tools (MCP) to the agent; default true. */
29
40
  dinoxTools?: boolean;
41
+ /** Default `ask`. */
42
+ approval?: GatewayApprovalMode;
43
+ /** Stays online but refuses new turns, so the owner can resume it from the app. */
44
+ paused?: boolean;
30
45
  createdAt: string;
31
46
  };
32
47
  export type GatewayToolStatus = 'pending' | 'running' | 'completed' | 'failed';
@@ -82,7 +97,11 @@ export type GatewayStopReason = 'end_turn' | 'cancelled' | 'max_tokens' | 'refus
82
97
  export type GatewayTurnInput = {
83
98
  /** Agent session to continue; absent starts a new one. */
84
99
  sessionId: string | null;
100
+ /** Working directory of the session (a new one starts here; a continued one is loaded with it). */
101
+ cwd: string;
85
102
  prompt: string;
103
+ /** Prepended to the prompt when the turn starts a new session, e.g. the agent's instructions. */
104
+ sessionPreamble?: string;
86
105
  /** Attachments the user sent with this turn (trusted, https). */
87
106
  resources: Array<{
88
107
  uri: string;
@@ -3,7 +3,8 @@
3
3
  * Claude Code, Codex, ... over ACP) as remote devices the Dinox app can drive.
4
4
  *
5
5
  * The app sees each gateway as one `dinox.remote-device.v2` device. The agent
6
- * owns its conversation context (gateway-session mode): each turn sends only
7
- * the new user message, while Dinox keeps a display/sync copy of the transcript.
6
+ * owns its conversation context (gateway-session mode): each turn sends the new
7
+ * user message plus any transcript messages its session has not seen (another
8
+ * agent answered meanwhile), while Dinox keeps the synced copy of the transcript.
8
9
  */
9
10
  export {};
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Folders an agent may work in, chosen from the app. The rules keep a remote
3
+ * choice inside the owner's own files: an existing directory under the home
4
+ * directory, never the home directory itself, nothing hidden (`~/.ssh`) and
5
+ * nothing in `~/Library`. Not a security boundary on its own (an agent with a
6
+ * shell can still `cd`); it keeps a tap from pointing an agent somewhere wrong.
7
+ */
8
+ export type WorkspaceFolderErrorCode = 'not_found' | 'not_allowed';
9
+ export declare class WorkspaceFolderError extends Error {
10
+ readonly code: WorkspaceFolderErrorCode;
11
+ constructor(code: WorkspaceFolderErrorCode, message: string);
12
+ }
13
+ export type WorkspaceFolder = {
14
+ name: string;
15
+ path: string;
16
+ /** Has a `.git` entry: likely a project the user works on. */
17
+ isProject: boolean;
18
+ };
19
+ /** The folder an agent may work in, resolved to its real path. */
20
+ export declare function resolveWorkspaceFolder(input: string, home?: string): Promise<string>;
21
+ /** `~/Dinox Agents/<agent name>`: a folder of the agent's own, offered as the default choice. */
22
+ export declare function agentHomeFolder(agentName: string, home?: string): string;
23
+ /** Creates the agent's own folder when missing; the only folder the app can create. */
24
+ export declare function ensureAgentHomeFolder(agentName: string, home?: string): Promise<string>;
25
+ /** The visible subfolders of a folder (the home folder included), for browsing. */
26
+ export declare function listWorkspaceFolders(input: string | null | undefined, home?: string): Promise<{
27
+ path: string;
28
+ parent: string | null;
29
+ folders: WorkspaceFolder[];
30
+ truncated: boolean;
31
+ }>;
32
+ /** Folders worth offering first: the given candidates that exist and are allowed, deduplicated. */
33
+ export declare function existingWorkspaceFolders(candidates: Array<string | null | undefined>, home?: string): Promise<WorkspaceFolder[]>;
@@ -0,0 +1,129 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ export class WorkspaceFolderError extends Error {
5
+ code;
6
+ constructor(code, message) {
7
+ super(message);
8
+ this.code = code;
9
+ this.name = 'WorkspaceFolderError';
10
+ }
11
+ }
12
+ const MAX_FOLDERS = 200;
13
+ /** Home-level folders that hold system data rather than the user's work. */
14
+ const SYSTEM_FOLDERS = new Set(['Library', 'Applications']);
15
+ const SKIPPED_FOLDERS = new Set(['node_modules']);
16
+ const AGENT_HOME_PARENT = 'Dinox Agents';
17
+ async function realHome(home) {
18
+ return fs.realpath(home).catch(() => path.resolve(home));
19
+ }
20
+ function expandHome(input, home) {
21
+ if (input === '~')
22
+ return home;
23
+ if (input.startsWith('~/'))
24
+ return path.join(home, input.slice(2));
25
+ return input;
26
+ }
27
+ /** Segments of `target` below `home`, or null when it is not inside it. */
28
+ function segmentsBelow(home, target) {
29
+ const relative = path.relative(home, target);
30
+ if (relative === '')
31
+ return [];
32
+ if (relative.startsWith('..') || path.isAbsolute(relative))
33
+ return null;
34
+ return relative.split(path.sep);
35
+ }
36
+ function isAllowedBelowHome(segments) {
37
+ if (segments.some((segment) => segment.startsWith('.')))
38
+ return false;
39
+ return !SYSTEM_FOLDERS.has(segments[0] ?? '');
40
+ }
41
+ async function resolveDirectory(input, home) {
42
+ const expanded = expandHome(input.trim(), home);
43
+ if (!path.isAbsolute(expanded)) {
44
+ throw new WorkspaceFolderError('not_allowed', 'Folder must be an absolute path');
45
+ }
46
+ // Refused by its name whether or not it exists (`~/.ssh`), so the answer
47
+ // never depends on what this computer happens to have.
48
+ const named = segmentsBelow(path.resolve(home), path.resolve(expanded));
49
+ if (named !== null && !isAllowedBelowHome(named)) {
50
+ throw new WorkspaceFolderError('not_allowed', 'This folder cannot be used');
51
+ }
52
+ let real;
53
+ try {
54
+ real = await fs.realpath(expanded);
55
+ }
56
+ catch {
57
+ throw new WorkspaceFolderError('not_found', 'Folder does not exist');
58
+ }
59
+ const stat = await fs.stat(real);
60
+ if (!stat.isDirectory())
61
+ throw new WorkspaceFolderError('not_found', 'Not a folder');
62
+ const segments = segmentsBelow(await realHome(home), real);
63
+ if (segments === null || !isAllowedBelowHome(segments)) {
64
+ throw new WorkspaceFolderError('not_allowed', 'This folder cannot be used');
65
+ }
66
+ return { real, segments };
67
+ }
68
+ /** The folder an agent may work in, resolved to its real path. */
69
+ export async function resolveWorkspaceFolder(input, home = os.homedir()) {
70
+ const { real, segments } = await resolveDirectory(input, home);
71
+ if (segments.length === 0) {
72
+ throw new WorkspaceFolderError('not_allowed', 'Choose a folder inside your home folder');
73
+ }
74
+ return real;
75
+ }
76
+ /** `~/Dinox Agents/<agent name>`: a folder of the agent's own, offered as the default choice. */
77
+ export function agentHomeFolder(agentName, home = os.homedir()) {
78
+ const safeName = agentName.replace(/[\u0000-\u001f\u007f/\\:*?"<>|]/g, '').trim() || 'Agent';
79
+ return path.join(home, AGENT_HOME_PARENT, safeName.slice(0, 64));
80
+ }
81
+ /** Creates the agent's own folder when missing; the only folder the app can create. */
82
+ export async function ensureAgentHomeFolder(agentName, home = os.homedir()) {
83
+ const folder = agentHomeFolder(agentName, home);
84
+ await fs.mkdir(folder, { recursive: true, mode: 0o755 });
85
+ return resolveWorkspaceFolder(folder, home);
86
+ }
87
+ async function hasGit(folder) {
88
+ return fs
89
+ .access(path.join(folder, '.git'))
90
+ .then(() => true)
91
+ .catch(() => false);
92
+ }
93
+ /** The visible subfolders of a folder (the home folder included), for browsing. */
94
+ export async function listWorkspaceFolders(input, home = os.homedir()) {
95
+ const { real, segments } = await resolveDirectory(input?.trim() || home, home);
96
+ const entries = await fs.readdir(real, { withFileTypes: true });
97
+ const names = entries
98
+ .filter((entry) => entry.isDirectory())
99
+ .map((entry) => entry.name)
100
+ .filter((name) => !name.startsWith('.') && !SKIPPED_FOLDERS.has(name))
101
+ .filter((name) => segments.length > 0 || !SYSTEM_FOLDERS.has(name))
102
+ .sort((a, b) => a.localeCompare(b));
103
+ const shown = names.slice(0, MAX_FOLDERS);
104
+ const folders = await Promise.all(shown.map(async (name) => {
105
+ const folder = path.join(real, name);
106
+ return { name, path: folder, isProject: await hasGit(folder) };
107
+ }));
108
+ return {
109
+ path: real,
110
+ parent: segments.length === 0 ? null : path.dirname(real),
111
+ folders,
112
+ truncated: names.length > shown.length,
113
+ };
114
+ }
115
+ /** Folders worth offering first: the given candidates that exist and are allowed, deduplicated. */
116
+ export async function existingWorkspaceFolders(candidates, home = os.homedir()) {
117
+ const seen = new Set();
118
+ const folders = [];
119
+ for (const candidate of candidates) {
120
+ if (!candidate)
121
+ continue;
122
+ const folder = await resolveWorkspaceFolder(candidate, home).catch(() => null);
123
+ if (!folder || seen.has(folder))
124
+ continue;
125
+ seen.add(folder);
126
+ folders.push({ name: path.basename(folder), path: folder, isProject: await hasGit(folder) });
127
+ }
128
+ return folders;
129
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dinoxx/dinox-cli",
3
- "version": "2.4.0",
3
+ "version": "2.5.2",
4
4
  "description": "Dinox CLI",
5
5
  "main": "dist/dinox.js",
6
6
  "scripts": {