@xmanrui/dsh-im 4.21.1 → 4.22.0

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,4 +1,5 @@
1
1
  import { validateBotAlias, withBotAlias } from './bot-alias.mjs';
2
+ import { defaultImWorkspace, sameWorkspacePath } from './default-workspace.mjs';
2
3
  import {
3
4
  mkdir,
4
5
  readFile,
@@ -52,15 +53,6 @@ async function canonicalWorkspacePath(value) {
52
53
  return resolve(await realpath(value));
53
54
  }
54
55
 
55
- async function sameWorkspacePath(left, right) {
56
- if (left === right) return true;
57
- try {
58
- return await canonicalWorkspacePath(left) === await canonicalWorkspacePath(right);
59
- } catch {
60
- return false;
61
- }
62
- }
63
-
64
56
  function botIdOf(value) {
65
57
  if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(value)) {
66
58
  throw new TypeError('Invalid bot id');
@@ -420,7 +412,7 @@ export class BotWorkspaceStore {
420
412
  #writeQueue = Promise.resolve();
421
413
  #botQueues = new Map();
422
414
 
423
- constructor(path, { defaultWorkspace = process.cwd() } = {}) {
415
+ constructor(path, { defaultWorkspace = defaultImWorkspace() } = {}) {
424
416
  if (typeof path !== 'string' || !path) throw new TypeError('workspace store path is required');
425
417
  this.#path = path;
426
418
  this.#defaultWorkspace = resolve(defaultWorkspace);
@@ -1597,6 +1589,7 @@ export function createBotWorkspaceScope(
1597
1589
  if (!sessionId) return true;
1598
1590
  let sessionWorkspace = sessionGenerations.get(sessionId)?.workspace;
1599
1591
  let matches = false;
1592
+ let unregistered = true;
1600
1593
  if (!sessionWorkspace && typeof harness.rpc === 'function') {
1601
1594
  // Read registration metadata only: adopting a Session is not a lookup.
1602
1595
  // Resolve by id before comparing real paths so symlink pins remain valid.
@@ -1609,7 +1602,11 @@ export function createBotWorkspaceScope(
1609
1602
  if (owners.length === 1 && typeof owners[0].path === 'string' && isAbsolute(owners[0].path)) {
1610
1603
  sessionWorkspace = owners[0].path;
1611
1604
  }
1612
- } else if (!sessionWorkspace && typeof harness.listWorkspaceSessions === 'function') {
1605
+ unregistered = owners.length === 0;
1606
+ }
1607
+ if (!sessionWorkspace && unregistered && typeof harness.listWorkspaceSessions === 'function') {
1608
+ // After a restart, default IM sessions have no registry owner. Reuse
1609
+ // the read-only list, which also recognizes ungrouped default sessions.
1613
1610
  const listed = await harness.listWorkspaceSessions(await canonicalWorkspacePath(workspace));
1614
1611
  if (!Array.isArray(listed?.sessions)) {
1615
1612
  throw new TypeError('Harness returned an invalid workspace session list');
@@ -0,0 +1,28 @@
1
+ import { mkdir, realpath } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, join, resolve } from 'node:path';
4
+
5
+ export function defaultImWorkspace(config = {}) {
6
+ const dshHome = config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh');
7
+ return resolve(dshHome, 'im');
8
+ }
9
+
10
+ export async function sameWorkspacePath(left, right) {
11
+ if (typeof left !== 'string' || typeof right !== 'string'
12
+ || !isAbsolute(left) || !isAbsolute(right)) return false;
13
+ if (resolve(left) === resolve(right)) return true;
14
+ try {
15
+ return resolve(await realpath(left)) === resolve(await realpath(right));
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ export async function prepareBotWorkspace(config = {}) {
22
+ const ungroupedWorkspace = defaultImWorkspace(config);
23
+ const defaultWorkspace = resolve(config.workspace ?? ungroupedWorkspace);
24
+ if (await sameWorkspacePath(defaultWorkspace, ungroupedWorkspace)) {
25
+ await mkdir(defaultWorkspace, { recursive: true });
26
+ }
27
+ return { defaultWorkspace, ungroupedWorkspace };
28
+ }
@@ -1,8 +1,10 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { mkdir } from 'node:fs/promises';
3
4
  import { isAbsolute } from 'node:path';
4
5
 
5
6
  import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs';
7
+ import { sameWorkspacePath } from './default-workspace.mjs';
6
8
  import {
7
9
  appendInboundFilesToPrompt,
8
10
  InboundFileError,
@@ -717,6 +719,7 @@ export class HarnessClient {
717
719
  #baseUrl;
718
720
  #apiProxy;
719
721
  #workspace;
722
+ #ungroupedWorkspace;
720
723
  #agentPreset;
721
724
  #autostart;
722
725
  #dshBin;
@@ -740,6 +743,7 @@ export class HarnessClient {
740
743
  apiProxy,
741
744
  interactionScope = apiProxy,
742
745
  workspace,
746
+ ungroupedWorkspace,
743
747
  agentPreset,
744
748
  autostart = false,
745
749
  dshBin = 'dsh',
@@ -788,6 +792,8 @@ export class HarnessClient {
788
792
  throw new TypeError('interactionScope must identify the current Host');
789
793
  }
790
794
  this.#workspace = workspace;
795
+ // Only the reserved IM directory bypasses grouping, even after /ws switches.
796
+ this.#ungroupedWorkspace = ungroupedWorkspace;
791
797
  // Keep an omitted preset absent so session.create resolves the Host's current default.
792
798
  this.#agentPreset = agentPreset ?? undefined;
793
799
  this.#autostart = Boolean(this.#baseUrl && autostart);
@@ -921,6 +927,38 @@ export class HarnessClient {
921
927
  await this.ensureRunning(options);
922
928
  const workspaceList = await this.rpc('workspace.list', {}, 30_000, options);
923
929
  const workspace = workspaceFromList(workspacePath, workspaceList);
930
+ if (await this.isUngroupedWorkspace(workspacePath)) {
931
+ const sessionList = await this.rpc('session.list', {}, 30_000, options);
932
+ if (!Array.isArray(sessionList?.items)) {
933
+ throw new Error('Harness returned an invalid response for session.list');
934
+ }
935
+ const registered = new Set();
936
+ const selected = new Set();
937
+ for (const item of workspaceList.items) {
938
+ if (!Array.isArray(item?.sessionIds)
939
+ || item.sessionIds.some((id) => typeof id !== 'string' || !id)) {
940
+ throw new Error('Harness returned invalid session IDs for workspace.list');
941
+ }
942
+ for (const id of item.sessionIds) registered.add(id);
943
+ if (await sameWorkspacePath(item.path, workspacePath)) {
944
+ for (const id of item.sessionIds) selected.add(id);
945
+ }
946
+ }
947
+ for (const item of sessionList.items) {
948
+ if (typeof item?.sessionId !== 'string' || !item.sessionId) {
949
+ throw new Error('Harness returned an invalid response for session.list');
950
+ }
951
+ // A matching cwd never overrides an explicit group assignment.
952
+ if (!registered.has(item.sessionId) && await this.isUngroupedWorkspace(item.cwd)) {
953
+ selected.add(item.sessionId);
954
+ }
955
+ }
956
+ return workspaceSessions(
957
+ { path: workspacePath, sessionIds: [...selected] },
958
+ workspaceList.archivedSessionIds,
959
+ sessionList,
960
+ );
961
+ }
924
962
  if (!workspace) return { workspace: workspacePath, sessions: [] };
925
963
  const sessionList = await this.rpc('session.list', {}, 30_000, options);
926
964
  return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList);
@@ -988,6 +1026,10 @@ export class HarnessClient {
988
1026
  return adoptRegisteredWorkspaceSession(this, value, options);
989
1027
  }
990
1028
 
1029
+ async isUngroupedWorkspace(workspace) {
1030
+ return sameWorkspacePath(workspace, this.#ungroupedWorkspace);
1031
+ }
1032
+
991
1033
  async workspaceId(options = {}) {
992
1034
  const { workspace = this.#workspace, ...rpcOptions } = options;
993
1035
  const { items } = await this.rpc('workspace.list', {}, 30_000, rpcOptions);
@@ -999,9 +1041,13 @@ export class HarnessClient {
999
1041
 
1000
1042
  async createSession(options = {}) {
1001
1043
  const { agentPreset: requestedPreset, ...rpcOptions } = options;
1044
+ const workspace = rpcOptions.workspace ?? this.#workspace;
1045
+ const ungrouped = await this.isUngroupedWorkspace(workspace);
1046
+ if (ungrouped) await mkdir(workspace, { recursive: true });
1002
1047
  await this.ensureRunning(rpcOptions);
1003
- const workspaceId = await this.workspaceId(rpcOptions);
1004
- const payload = { workspaceId };
1048
+ const payload = ungrouped
1049
+ ? { cwd: workspace }
1050
+ : { workspaceId: await this.workspaceId(rpcOptions) };
1005
1051
  const agentPreset = requestedPreset !== undefined ? requestedPreset : this.#agentPreset;
1006
1052
  if (agentPreset != null) payload.agentPreset = agentPreset;
1007
1053
  const created = await this.rpc('session.create', payload, 30_000, rpcOptions);
@@ -37,16 +37,13 @@ function sessionWorkspace(sessionId, value) {
37
37
  }
38
38
  }
39
39
 
40
- if (owners.length === 0) {
41
- throw bindingError('session-not-registered', 'The session is not registered to a Harness workspace');
42
- }
43
- if (owners.length !== 1) {
40
+ if (owners.length > 1) {
44
41
  throw bindingError(
45
42
  'session-workspace-ambiguous',
46
43
  'The session is registered to more than one Harness workspace',
47
44
  );
48
45
  }
49
- if (UNSAFE_WORKSPACE_PATH.test(owners[0].path)) {
46
+ if (owners.length && UNSAFE_WORKSPACE_PATH.test(owners[0].path)) {
50
47
  throw new Error('Harness returned an unsafe workspace path for the session');
51
48
  }
52
49
  return {
@@ -82,20 +79,28 @@ function sessionSummary(sessionId, value) {
82
79
  if (title !== undefined && title !== null && typeof title !== 'string') {
83
80
  throw new Error('Harness returned an invalid session title for session.list');
84
81
  }
85
- return { title: typeof title === 'string' ? title : null };
82
+ return { cwd: summary.cwd, title: typeof title === 'string' ? title : null };
86
83
  }
87
84
 
88
85
  export async function adoptRegisteredWorkspaceSession(client, value, options = {}, timeoutMs = 30_000) {
89
86
  const sessionId = validatedSessionId(value);
90
87
  await client.ensureRunning(options);
91
88
  const workspaceList = await client.rpc('workspace.list', {}, timeoutMs, options);
92
- const { workspace, archived } = sessionWorkspace(sessionId, workspaceList);
89
+ let { workspace, archived } = sessionWorkspace(sessionId, workspaceList);
93
90
  const summary = sessionSummary(
94
91
  sessionId,
95
92
  await client.rpc('session.list', {}, timeoutMs, options),
96
93
  );
94
+ if (!workspace) {
95
+ if (typeof summary.cwd !== 'string' || !isAbsolute(summary.cwd)
96
+ || UNSAFE_WORKSPACE_PATH.test(summary.cwd)
97
+ || !await client.isUngroupedWorkspace?.(summary.cwd)) {
98
+ throw bindingError('session-not-registered', 'The session is not registered to a Harness workspace');
99
+ }
100
+ workspace = { path: summary.cwd };
101
+ }
97
102
  const adopted = await client.rpc('session.create', {
98
- workspaceId: workspace.workspaceId,
103
+ ...(workspace.workspaceId ? { workspaceId: workspace.workspaceId } : { cwd: workspace.path }),
99
104
  sessionId,
100
105
  }, timeoutMs, options);
101
106
  if (!adopted || adopted.sessionId !== sessionId) {