@xmanrui/dsh-im 4.21.2 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.21.2",
3
+ "version": "4.22.0",
4
4
  "description": "把十一种 IM 渠道和公网 AI Office 接入本机 DeepSeek Harness。 Connect eleven IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -114,6 +114,7 @@
114
114
  "src",
115
115
  "PROACTIVE_DELIVERY.md",
116
116
  "PROACTIVE_DELIVERY.en.md",
117
+ "docs/client-integration.md",
117
118
  "LICENSE",
118
119
  "README.md",
119
120
  "README.en.md",
@@ -39,6 +39,7 @@ const EN = Object.freeze({
39
39
  'macOS Messages 权限': 'macOS Messages permissions',
40
40
  'IM机器人': 'IM bots',
41
41
  'IM机器人设置': 'IM bot settings',
42
+ 'IM 面板加载失败': 'Could not load the IM panel',
42
43
  '更多机器人设置': 'More bot settings',
43
44
  '机器人设置': 'Bot settings',
44
45
  '机器人设置页签': 'Bot settings tabs',
@@ -74,6 +74,7 @@ import { installImStyles } from './styles.js';
74
74
  import { installSessionChannelLogos } from './session-channel-logos.js';
75
75
  import { UpdatePanel, UPDATE_RPC_CHANNEL } from './update-panel.js';
76
76
  import { WorkspaceDirectoryPickerContext } from './workspace-editor.js';
77
+ import { IMPanelErrorBoundary } from './panel-error-boundary.js';
77
78
 
78
79
  export const name = 'im-settings';
79
80
  export const inject = ['slots', 'connection', 'locale', 'workspaces'];
@@ -209,10 +210,15 @@ export function IMSettingsTab({
209
210
  deliveryRpcCall,
210
211
  globalSettingsRpcCall,
211
212
  workspaceDirectoryPicker,
213
+ preferredSectionId,
212
214
  browserLocation = globalThis.location,
213
215
  navigateToRecoveryUrl = replacePageLocation,
214
216
  }) {
215
- const [selected, setSelected] = React.useState('weixin');
217
+ const [selected, setSelected] = React.useState(() => (
218
+ preferredSectionId === GLOBAL_SETTINGS_TAB_ID
219
+ || CHANNELS.some((channel) => channel.id === preferredSectionId)
220
+ ? preferredSectionId : 'weixin'
221
+ ));
216
222
  const [loopbackRecovery, setLoopbackRecovery] = React.useState(null);
217
223
  const [runningVersion, setRunningVersion] = React.useState(IM_PLUGIN_VERSION);
218
224
  const [deliverySettings, setDeliverySettings] = React.useState(null);
@@ -471,29 +477,89 @@ export function apply(ctx) {
471
477
  pickDirectory: () => callWorkspaceDirectoryApi(ctx, 'pickDirectory'),
472
478
  });
473
479
 
474
- ctx.slots.inject('settings.section', () => ctx.slots.register({
475
- name: 'settings.section',
476
- id: 'xmanrui-dsh-im',
477
- order: 21,
478
- label: () => t('IM机器人'),
479
- locale: IM_LOCALE_NAMESPACE,
480
- inject: () => ({
481
- dingtalkRpcCall,
482
- discordRpcCall,
483
- feishuRpcCall,
484
- qqRpcCall,
485
- slackRpcCall,
486
- telegramRpcCall,
487
- wecomRpcCall,
488
- wecomAppRpcCall,
489
- weixinRpcCall,
490
- whatsappRpcCall,
491
- imessageRpcCall,
492
- officeRpcCall,
493
- updateRpcCall,
494
- deliveryRpcCall,
495
- globalSettingsRpcCall,
496
- workspaceDirectoryPicker,
497
- }),
498
- }, IMSettingsTab));
480
+ const panelDependencies = {
481
+ dingtalkRpcCall,
482
+ discordRpcCall,
483
+ feishuRpcCall,
484
+ qqRpcCall,
485
+ slackRpcCall,
486
+ telegramRpcCall,
487
+ wecomRpcCall,
488
+ wecomAppRpcCall,
489
+ weixinRpcCall,
490
+ whatsappRpcCall,
491
+ imessageRpcCall,
492
+ officeRpcCall,
493
+ updateRpcCall,
494
+ deliveryRpcCall,
495
+ globalSettingsRpcCall,
496
+ workspaceDirectoryPicker,
497
+ };
498
+ const subscribeLocale = (listener) => typeof ctx.on === 'function'
499
+ ? ctx.on('locale/change', listener) : () => {};
500
+ const localeSnapshot = () => ctx.locale.getLocale?.()?.active ?? '';
501
+
502
+ // Stable for this plugin lifetime: creating an element must not create a
503
+ // new component type and reset the reader's selected tab or unsaved input.
504
+ function IMPanel({ preferredSectionId }) {
505
+ React.useSyncExternalStore(subscribeLocale, localeSnapshot, localeSnapshot);
506
+ return h(IMPanelErrorBoundary, null,
507
+ h(IMSettingsTab, { ...panelDependencies, preferredSectionId }));
508
+ }
509
+ const buildPanelElement = (props = {}) => h(IMPanel, {
510
+ preferredSectionId: props.preferredSectionId,
511
+ });
512
+
513
+ ctx.effect(() => {
514
+ let disposed = false;
515
+ let stopSettings = null;
516
+ let registered = false;
517
+ const setSettingsVisible = (visible) => {
518
+ if (disposed) return;
519
+ if (typeof visible !== 'boolean') throw new TypeError('visible must be a boolean');
520
+ if (visible === (stopSettings !== null)) return;
521
+ if (!visible) {
522
+ const stop = stopSettings;
523
+ stopSettings = null;
524
+ stop();
525
+ return;
526
+ }
527
+ // The existing slot controller owns late declarations, withdrawal and
528
+ // re-declaration. Cancelling it also cancels a pending registration.
529
+ stopSettings = ctx.slots.inject('settings.section', () => {
530
+ const unregister = ctx.slots.register({
531
+ name: 'settings.section',
532
+ id: 'xmanrui-dsh-im',
533
+ order: 21,
534
+ label: () => t('IM机器人'),
535
+ locale: IM_LOCALE_NAMESPACE,
536
+ inject: () => panelDependencies,
537
+ }, buildPanelElement);
538
+ registered = true;
539
+ return () => {
540
+ registered = false;
541
+ unregister();
542
+ };
543
+ });
544
+ };
545
+
546
+ // Publish only after the default registration is established, so a
547
+ // consumer hiding it during service discovery cannot be overridden.
548
+ setSettingsVisible(true);
549
+ if (typeof ctx.provide === 'function') {
550
+ ctx.provide('dshImClient', Object.freeze({
551
+ version: 1,
552
+ render: (props) => disposed ? null : buildPanelElement(props),
553
+ setSettingsVisible,
554
+ settingsVisible: () => !disposed && registered,
555
+ }));
556
+ }
557
+ return () => {
558
+ disposed = true;
559
+ const stop = stopSettings;
560
+ stopSettings = null;
561
+ registered = false;
562
+ stop?.();
563
+ };
564
+ }, 'im-settings: client panel service');
499
565
  }
@@ -0,0 +1,21 @@
1
+ import * as React from 'react';
2
+ import { h } from './i18n.js';
3
+
4
+ /** Keep a broken IM subtree local when a shell renders it outside a slot. */
5
+ export class IMPanelErrorBoundary extends React.Component {
6
+ state = { failed: false };
7
+
8
+ static getDerivedStateFromError() {
9
+ return { failed: true };
10
+ }
11
+
12
+ render() {
13
+ if (!this.state.failed) return this.props.children;
14
+ return h('section', { className: 'dim-page', role: 'alert' },
15
+ h('p', null, 'IM 面板加载失败'),
16
+ h('button', {
17
+ type: 'button',
18
+ onClick: () => this.setState({ failed: false }),
19
+ }, '重试'));
20
+ }
21
+ }
@@ -14,6 +14,7 @@ import {
14
14
  createWorkspaceAwareController,
15
15
  observeBotWorkspaceRemovals,
16
16
  } from '../../../../src/channels/shared/bot-workspace-store.mjs';
17
+ import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
17
18
  import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
18
19
  import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
19
20
  import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
@@ -58,7 +59,7 @@ export async function createProductionController(ctx, config = {}, internals = {
58
59
  const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
59
60
  const paths = pluginPaths(config);
60
61
  const configStore = await new ConfigStore(paths.config).load();
61
- const defaultWorkspace = resolve(config.workspace ?? process.cwd());
62
+ const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
62
63
  const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
63
64
  const workspaces = internals.workspaces
64
65
  ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
@@ -105,6 +106,7 @@ export async function createProductionController(ctx, config = {}, internals = {
105
106
  const harness = new Harness({
106
107
  ...connection,
107
108
  workspace: defaultWorkspace,
109
+ ungroupedWorkspace,
108
110
  autostart: false,
109
111
  dshBin: config.dshBin ?? 'dsh',
110
112
  ...(commandExecutor ? { commandExecutor } : {}),
@@ -26,6 +26,7 @@ import {
26
26
  createWorkspaceAwareController,
27
27
  observeBotWorkspaceRemovals,
28
28
  } from '../../../../src/channels/shared/bot-workspace-store.mjs';
29
+ import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
29
30
  import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
30
31
  import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
31
32
  import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
@@ -117,7 +118,7 @@ export async function createProductionController(ctx, config = {}, internals = {
117
118
  const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
118
119
  const paths = pluginPaths(config);
119
120
  const configStore = await new ConfigStore(paths.config).load();
120
- const defaultWorkspace = resolve(config.workspace ?? process.cwd());
121
+ const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
121
122
  const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
122
123
  const workspaces = internals.workspaces
123
124
  ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
@@ -201,6 +202,7 @@ export async function createProductionController(ctx, config = {}, internals = {
201
202
  const harness = new Harness({
202
203
  ...connection,
203
204
  workspace: defaultWorkspace,
205
+ ungroupedWorkspace,
204
206
  // This plugin is already hosted by a running DSH process. Starting a
205
207
  // second DSH would create a competing server and lifecycle.
206
208
  autostart: false,
@@ -14,6 +14,7 @@ import {
14
14
  createWorkspaceAwareController,
15
15
  observeBotWorkspaceRemovals,
16
16
  } from '../../../../src/channels/shared/bot-workspace-store.mjs';
17
+ import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
17
18
  import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
18
19
  import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
19
20
  import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
@@ -55,7 +56,7 @@ export async function createProductionController(ctx, config = {}, internals = {
55
56
  const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
56
57
  const paths = pluginPaths(config);
57
58
  const configStore = await new ConfigStore(paths.config).load();
58
- const defaultWorkspace = resolve(config.workspace ?? process.cwd());
59
+ const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
59
60
  const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
60
61
  const workspaces = internals.workspaces
61
62
  ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
@@ -96,6 +97,7 @@ export async function createProductionController(ctx, config = {}, internals = {
96
97
  const harness = new Harness({
97
98
  ...connection,
98
99
  workspace: defaultWorkspace,
100
+ ungroupedWorkspace,
99
101
  autostart: false,
100
102
  dshBin: config.dshBin ?? 'dsh',
101
103
  ...(commandExecutor ? { commandExecutor } : {}),
@@ -16,6 +16,7 @@ import {
16
16
  createWorkspaceAwareController,
17
17
  observeBotWorkspaceRemovals,
18
18
  } from '../../../../src/channels/shared/bot-workspace-store.mjs';
19
+ import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
19
20
  import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
20
21
  import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
21
22
  import {
@@ -65,7 +66,7 @@ export async function createTokenProductionController(ctx, config, internals, de
65
66
  const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
66
67
  const paths = pluginPaths(config, channel);
67
68
  const configStore = await new ResolvedConfigStore(paths.config).load();
68
- const defaultWorkspace = resolve(config.workspace ?? process.cwd());
69
+ const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
69
70
  const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
70
71
  const workspaces = internals.workspaces
71
72
  ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
@@ -105,6 +106,7 @@ export async function createTokenProductionController(ctx, config, internals, de
105
106
  const harness = new ResolvedHarness({
106
107
  ...connection,
107
108
  workspace: defaultWorkspace,
109
+ ungroupedWorkspace,
108
110
  autostart: false,
109
111
  dshBin: config.dshBin ?? 'dsh',
110
112
  ...(commandExecutor ? { commandExecutor } : {}),
@@ -12,6 +12,7 @@ import {
12
12
  createWorkspaceAwareController,
13
13
  observeBotWorkspaceRemovals,
14
14
  } from '../../../../src/channels/shared/bot-workspace-store.mjs';
15
+ import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
15
16
  import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
16
17
  import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
17
18
  import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
@@ -44,7 +45,7 @@ export async function createProductionController(ctx, config = {}, internals = {
44
45
  const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
45
46
  const paths = pluginPaths(config, 'slack');
46
47
  const configStore = await new ResolvedConfigStore(paths.config).load();
47
- const defaultWorkspace = resolve(config.workspace ?? process.cwd());
48
+ const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
48
49
  const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
49
50
  const workspaces = internals.workspaces
50
51
  ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
@@ -84,6 +85,7 @@ export async function createProductionController(ctx, config = {}, internals = {
84
85
  const harness = new ResolvedHarness({
85
86
  ...connection,
86
87
  workspace: defaultWorkspace,
88
+ ungroupedWorkspace,
87
89
  autostart: false,
88
90
  dshBin: config.dshBin ?? 'dsh',
89
91
  ...(commandExecutor ? { commandExecutor } : {}),
@@ -14,6 +14,7 @@ import {
14
14
  createWorkspaceAwareController,
15
15
  observeBotWorkspaceRemovals,
16
16
  } from '../../../../src/channels/shared/bot-workspace-store.mjs';
17
+ import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
17
18
  import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
18
19
  import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
19
20
  import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
@@ -55,7 +56,7 @@ export async function createProductionController(ctx, config = {}, internals = {
55
56
  const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
56
57
  const paths = pluginPaths(config);
57
58
  const configStore = await new ConfigStore(paths.config).load();
58
- const defaultWorkspace = resolve(config.workspace ?? process.cwd());
59
+ const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
59
60
  const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
60
61
  const workspaces = internals.workspaces
61
62
  ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
@@ -99,6 +100,7 @@ export async function createProductionController(ctx, config = {}, internals = {
99
100
  const harness = new Harness({
100
101
  ...connection,
101
102
  workspace: defaultWorkspace,
103
+ ungroupedWorkspace,
102
104
  autostart: false,
103
105
  dshBin: config.dshBin ?? 'dsh',
104
106
  ...(commandExecutor ? { commandExecutor } : {}),
@@ -14,6 +14,7 @@ import {
14
14
  createWorkspaceAwareController,
15
15
  observeBotWorkspaceRemovals,
16
16
  } from '../../../../src/channels/shared/bot-workspace-store.mjs';
17
+ import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
17
18
  import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
18
19
  import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
19
20
  import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
@@ -69,7 +70,7 @@ export async function createProductionController(ctx, config = {}, internals = {
69
70
  const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
70
71
  const paths = pluginPaths(config);
71
72
  const configStore = await new ConfigStore(paths.config).load();
72
- const defaultWorkspace = resolve(config.workspace ?? process.cwd());
73
+ const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
73
74
  const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
74
75
  const workspaces = internals.workspaces
75
76
  ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
@@ -133,6 +134,7 @@ export async function createProductionController(ctx, config = {}, internals = {
133
134
  const harness = new Harness({
134
135
  ...connection,
135
136
  workspace: defaultWorkspace,
137
+ ungroupedWorkspace,
136
138
  autostart: false,
137
139
  dshBin: config.dshBin ?? 'dsh',
138
140
  ...(commandExecutor ? { commandExecutor } : {}),
@@ -18,6 +18,7 @@ import {
18
18
  createWorkspaceAwareController,
19
19
  observeBotWorkspaceRemovals,
20
20
  } from '../../../../src/channels/shared/bot-workspace-store.mjs';
21
+ import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
21
22
  import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
22
23
  import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
23
24
  import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
@@ -90,7 +91,7 @@ export async function createProductionController(ctx, config = {}, internals = {
90
91
  const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
91
92
  const paths = pluginPaths(config);
92
93
  const configStore = await new ConfigStore(paths.config).load();
93
- const defaultWorkspace = resolve(config.workspace ?? process.cwd());
94
+ const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
94
95
  const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
95
96
  const workspaces = diagnosticWorkspaces(internals.workspaces
96
97
  ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load());
@@ -131,6 +132,7 @@ export async function createProductionController(ctx, config = {}, internals = {
131
132
  const harness = new Harness({
132
133
  ...connection,
133
134
  workspace: defaultWorkspace,
135
+ ungroupedWorkspace,
134
136
  autostart: false,
135
137
  dshBin: config.dshBin ?? 'dsh',
136
138
  ...(commandExecutor ? { commandExecutor } : {}),
@@ -17,6 +17,7 @@ import {
17
17
  createWorkspaceAwareController,
18
18
  observeBotWorkspaceRemovals,
19
19
  } from '../../../../src/channels/shared/bot-workspace-store.mjs';
20
+ import { prepareBotWorkspace } from '../../../../src/channels/shared/default-workspace.mjs';
20
21
  import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
21
22
  import { listModelCatalog } from '../../../../src/channels/shared/model-setting.mjs';
22
23
  import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
@@ -65,7 +66,7 @@ export async function createProductionController(ctx, config = {}, internals = {
65
66
  const createSupervisor = internals.createConnectionSupervisor ?? createTokenConnectionSupervisor;
66
67
  const paths = pluginPaths(config);
67
68
  const configStore = await new ConfigStore(paths.config).load();
68
- const defaultWorkspace = resolve(config.workspace ?? process.cwd());
69
+ const { defaultWorkspace, ungroupedWorkspace } = await prepareBotWorkspace(config);
69
70
  const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
70
71
  const workspaces = internals.workspaces
71
72
  ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
@@ -105,6 +106,7 @@ export async function createProductionController(ctx, config = {}, internals = {
105
106
  const harness = new Harness({
106
107
  ...connection,
107
108
  workspace: defaultWorkspace,
109
+ ungroupedWorkspace,
108
110
  autostart: false,
109
111
  dshBin: config.dshBin ?? 'dsh',
110
112
  ...(commandExecutor ? { commandExecutor } : {}),
@@ -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) {