@xmanrui/dsh-im 4.9.0 → 4.10.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.
Files changed (47) hide show
  1. package/PROACTIVE_DELIVERY.en.md +8 -0
  2. package/PROACTIVE_DELIVERY.md +8 -0
  3. package/lib/client.js +679 -73
  4. package/lib/index.js +276 -275
  5. package/package.json +21 -6
  6. package/plugin-src/client/channels/dingtalk/api.js +23 -6
  7. package/plugin-src/client/channels/dingtalk/index.js +33 -6
  8. package/plugin-src/client/channels/dingtalk/styles.js +2 -0
  9. package/plugin-src/client/channels/feishu/api.js +2 -0
  10. package/plugin-src/client/channels/feishu/index.js +54 -0
  11. package/plugin-src/client/global-settings.js +341 -0
  12. package/plugin-src/client/i18n.js +33 -0
  13. package/plugin-src/client/index.js +47 -6
  14. package/plugin-src/client/styles.js +49 -0
  15. package/plugin-src/host/channels/dingtalk/production.mjs +12 -0
  16. package/plugin-src/host/channels/dingtalk/rpc.mjs +24 -0
  17. package/plugin-src/host/channels/feishu/production.mjs +14 -0
  18. package/plugin-src/host/channels/feishu/rpc.mjs +16 -0
  19. package/plugin-src/host/channels/qq/production.mjs +12 -0
  20. package/plugin-src/host/channels/shared/production.mjs +12 -0
  21. package/plugin-src/host/channels/slack/production.mjs +12 -0
  22. package/plugin-src/host/channels/wecom/production.mjs +12 -0
  23. package/plugin-src/host/channels/weixin/production.mjs +12 -0
  24. package/plugin-src/host/channels/whatsapp/production.mjs +12 -0
  25. package/plugin-src/host/delivery-adapter.mjs +1 -0
  26. package/plugin-src/host/delivery-service.mjs +15 -0
  27. package/plugin-src/host/harness-session-coordinator.mjs +26 -5
  28. package/plugin-src/host/inbound-ttl-rpc.mjs +83 -0
  29. package/plugin-src/host/inbound-ttl-runtime.mjs +85 -0
  30. package/plugin-src/host/inbound-ttl-service.mjs +179 -0
  31. package/plugin-src/host/index.mjs +8 -0
  32. package/plugin-src/host/modern-harness-api.mjs +75 -10
  33. package/src/channels/dingtalk/connection-error.mjs +299 -0
  34. package/src/channels/dingtalk/dingtalk-controller.mjs +79 -34
  35. package/src/channels/dingtalk/dingtalk-runtime.mjs +9 -2
  36. package/src/channels/feishu/bridge.mjs +164 -4
  37. package/src/channels/feishu/feishu-channel.mjs +45 -10
  38. package/src/channels/feishu/feishu-runtime.mjs +9 -0
  39. package/src/channels/feishu/message-utils.mjs +13 -0
  40. package/src/channels/feishu/multi-bot-controller.mjs +16 -0
  41. package/src/channels/feishu/plugin-config-store.mjs +1 -0
  42. package/src/channels/feishu/state-store.mjs +25 -0
  43. package/src/channels/shared/bot-workspace-store.mjs +6 -0
  44. package/src/channels/shared/i18n-en/dingtalk.mjs +23 -0
  45. package/src/channels/shared/inbound-file.mjs +117 -2
  46. package/src/channels/shared/inbound-ttl-store.mjs +110 -0
  47. package/src/channels/shared/inbound-ttl.mjs +22 -0
@@ -22,7 +22,9 @@ function agentsFromContext(ctx) {
22
22
 
23
23
  function currentOwnedTurn(agent, expectedTurn, promptRpcId) {
24
24
  if (agent?.status !== 'running') return false;
25
- const events = agent?.session?.events;
25
+ const events = typeof agent?.session?.snapshotEvents === 'function'
26
+ ? agent.session.snapshotEvents()
27
+ : agent?.session?.events;
26
28
  if (!Array.isArray(events)) return false;
27
29
 
28
30
  let openTurn = null;
@@ -113,7 +115,7 @@ function createSessionMaintenanceExecutor(agents) {
113
115
  };
114
116
  }
115
117
 
116
- function createFileIngressExecutor(agents) {
118
+ function createFileIngressExecutor(agents, { inboundTtlService } = {}) {
117
119
  return ({ sessionId, workspace, files, signal }) => {
118
120
  const agent = agents.get(sessionId);
119
121
  const attachedWorkspace = agent?.session?.header?.cwd;
@@ -126,7 +128,19 @@ function createFileIngressExecutor(agents) {
126
128
  'The Harness Session workspace is unavailable for inbound files.',
127
129
  );
128
130
  }
129
- return stageInboundFiles({ files }, { workspace: exactWorkspace, signal });
131
+ const staged = stageInboundFiles({ files }, {
132
+ workspace: exactWorkspace,
133
+ signal,
134
+ ...(inboundTtlService ? { retention: inboundTtlService.stagingRetention() } : {}),
135
+ // Registration at creation time closes the mid-staging sweep window;
136
+ // trackStaged later re-registers idempotently and owns the release.
137
+ ...(typeof inboundTtlService?.trackDirectory === 'function'
138
+ ? { onStagedDirectory: (directory) => inboundTtlService.trackDirectory(directory) }
139
+ : {}),
140
+ });
141
+ return inboundTtlService
142
+ ? staged.then((result) => (result ? inboundTtlService.trackStaged(result) : result))
143
+ : staged;
130
144
  };
131
145
  }
132
146
 
@@ -136,7 +150,9 @@ function createFileIngressExecutor(agents) {
136
150
  * when an Agent is attached, its live Session header remains authoritative.
137
151
  */
138
152
  export function createHarnessSessionExecutors(ctx, provided = {}) {
139
- const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = provided;
153
+ const {
154
+ controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor, inboundTtlService,
155
+ } = provided;
140
156
  if (controlExecutor !== undefined && typeof controlExecutor !== 'function') {
141
157
  throw new TypeError('controlExecutor must be a function');
142
158
  }
@@ -147,6 +163,11 @@ export function createHarnessSessionExecutors(ctx, provided = {}) {
147
163
  if (fileIngressExecutor !== undefined && typeof fileIngressExecutor !== 'function') {
148
164
  throw new TypeError('fileIngressExecutor must be a function');
149
165
  }
166
+ if (inboundTtlService !== undefined
167
+ && (typeof inboundTtlService?.stagingRetention !== 'function'
168
+ || typeof inboundTtlService?.trackStaged !== 'function')) {
169
+ throw new TypeError('inboundTtlService must expose stagingRetention() and trackStaged()');
170
+ }
150
171
 
151
172
  const agents = controlExecutor && sessionMaintenanceExecutor && fileIngressExecutor
152
173
  ? undefined
@@ -156,6 +177,6 @@ export function createHarnessSessionExecutors(ctx, provided = {}) {
156
177
  sessionMaintenanceExecutor: sessionMaintenanceExecutor
157
178
  ?? (agents ? createSessionMaintenanceExecutor(agents) : undefined),
158
179
  fileIngressExecutor: fileIngressExecutor
159
- ?? createFileIngressExecutor(agents ?? { get: () => undefined }),
180
+ ?? createFileIngressExecutor(agents ?? { get: () => undefined }, { inboundTtlService }),
160
181
  };
161
182
  }
@@ -0,0 +1,83 @@
1
+ import { normalizeInboundTtlHours } from '../../src/channels/shared/inbound-ttl.mjs';
2
+ import { getInboundTtlRuntime } from './inbound-ttl-runtime.mjs';
3
+
4
+ export const INBOUND_TTL_RPC_CHANNEL = '/dsh-im-settings';
5
+ export const INBOUND_TTL_ENDPOINTS = Object.freeze({
6
+ get: 'settings.inbound-ttl.get',
7
+ set: 'settings.inbound-ttl.set',
8
+ sweep: 'settings.inbound-ttl.sweep',
9
+ });
10
+
11
+ const ENDPOINTS = new Set(Object.values(INBOUND_TTL_ENDPOINTS));
12
+
13
+ export function validInboundTtlPayload(endpoint, payload) {
14
+ if (!ENDPOINTS.has(endpoint)) return false;
15
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return false;
16
+ const keys = Object.keys(payload);
17
+ if (endpoint === INBOUND_TTL_ENDPOINTS.set) {
18
+ return keys.length === 1 && keys[0] === 'ttlHours'
19
+ && normalizeInboundTtlHours(payload.ttlHours) !== null;
20
+ }
21
+ return keys.length === 0;
22
+ }
23
+
24
+ export function createInboundTtlRpcHandler({ store, service, logger = null } = {}) {
25
+ if (!store || typeof store.getTtlHours !== 'function'
26
+ || typeof store.setTtlHours !== 'function'
27
+ || !service || typeof service.sweepNow !== 'function') {
28
+ throw new TypeError('createInboundTtlRpcHandler requires an inbound TTL store and service');
29
+ }
30
+ return async (endpoint, payload, signal) => {
31
+ if (!validInboundTtlPayload(endpoint, payload)) {
32
+ return { ok: false, error: { code: 'bad-request', message: 'Invalid inbound TTL request.' } };
33
+ }
34
+ if (signal?.aborted) {
35
+ return { ok: false, error: { code: 'cancelled', message: 'Request cancelled.' } };
36
+ }
37
+ try {
38
+ if (endpoint === INBOUND_TTL_ENDPOINTS.get) {
39
+ return { ok: true, value: { ttlHours: store.getTtlHours() } };
40
+ }
41
+ if (endpoint === INBOUND_TTL_ENDPOINTS.set) {
42
+ const ttlHours = await store.setTtlHours(payload.ttlHours);
43
+ // Re-arm the sweep interval so the saved value gets a full window
44
+ // before its first scheduled enforcement.
45
+ service.resetSweepSchedule?.();
46
+ return { ok: true, value: { ttlHours } };
47
+ }
48
+ const summary = await service.sweepNow();
49
+ // The UI shows no result text, so the host log is the only place a
50
+ // manual sweep can be diagnosed (TTL applied, coverage, deletions).
51
+ logger?.info?.(
52
+ `[dsh-im] manual inbound attachment sweep: ${summary.deletedDirectories} deleted`
53
+ + ` across ${summary.sweptWorkspaces} workspaces (ttl ${store.getTtlHours()}h)`,
54
+ );
55
+ return { ok: true, value: summary };
56
+ } catch (error) {
57
+ if (error?.code === 'inbound-ttl-invalid') {
58
+ return {
59
+ ok: false,
60
+ error: {
61
+ code: 'inbound-ttl-invalid',
62
+ message: '附件保留时长无效:请输入 -1、0 或 1-8760 之间的整数小时数。',
63
+ },
64
+ };
65
+ }
66
+ return { ok: false, error: { code: 'inbound-ttl-unavailable', message: 'inbound-ttl-unavailable' } };
67
+ }
68
+ };
69
+ }
70
+
71
+ export function installInboundTtlRpc(ctx, options = {}) {
72
+ if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
73
+ throw new TypeError('DSH Host Connection RPC is required');
74
+ }
75
+ const runtime = options.runtime ?? getInboundTtlRuntime(ctx, options.config);
76
+ const logger = typeof ctx?.logger === 'function'
77
+ ? ctx.logger('dsh-im:inbound-ttl') : (ctx?.logger ?? null);
78
+ return ctx.connection.rpc.handle(
79
+ INBOUND_TTL_RPC_CHANNEL,
80
+ createInboundTtlRpcHandler({ ...runtime, logger }),
81
+ { authority: 'loopback' },
82
+ );
83
+ }
@@ -0,0 +1,85 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, resolve } from 'node:path';
3
+
4
+ import { InboundTtlStore } from '../../src/channels/shared/inbound-ttl-store.mjs';
5
+ import { createInboundTtlService } from './inbound-ttl-service.mjs';
6
+
7
+ const runtimes = new Map();
8
+
9
+ /**
10
+ * Resolve the global settings file shared by every channel. The dshHome
11
+ * resolution mirrors pluginPaths: config.dshHome, then DSH_HOME, then the
12
+ * user's home directory. Channel-specific dataDir values never apply.
13
+ */
14
+ export function inboundTtlSettingsPath(config = {}) {
15
+ const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
16
+ return resolve(dshHome, 'integrations', 'dsh-im', 'settings.json');
17
+ }
18
+
19
+ /**
20
+ * Per-process inbound TTL runtime, cached by resolved settings path so every
21
+ * channel shares one store, one staged registry, and one sweep timer. The
22
+ * entry is dropped (and the sweeper stopped) when the owning context is
23
+ * disposed, letting a later activation rebuild from disk.
24
+ */
25
+ export function getInboundTtlRuntime(ctx, config = {}) {
26
+ const settingsPath = inboundTtlSettingsPath(config);
27
+ const existing = runtimes.get(settingsPath);
28
+ if (existing) return existing.runtime;
29
+ const logger = typeof ctx?.logger === 'function'
30
+ ? ctx.logger('dsh-im:inbound-ttl') : (ctx?.logger ?? console);
31
+ const store = new InboundTtlStore(settingsPath);
32
+ const service = createInboundTtlService({ store, logger });
33
+ const runtime = Object.freeze({ store, service });
34
+ const entry = { runtime };
35
+ runtimes.set(settingsPath, entry);
36
+ // Load persisted settings before the first sweep so a keep-forever TTL can
37
+ // never be overtaken by the in-memory default. An unreadable store keeps
38
+ // sweeping disabled rather than deleting files of unknown intent.
39
+ void store.load().then(
40
+ () => service.start(),
41
+ (error) => logger.error?.('[dsh-im] unable to load inbound attachment settings; sweeping stays disabled', error),
42
+ );
43
+ if (typeof ctx?.effect === 'function') {
44
+ ctx.effect(() => () => {
45
+ if (runtimes.get(settingsPath) !== entry) return;
46
+ runtimes.delete(settingsPath);
47
+ service.stop();
48
+ }, 'dsh-im: stop inbound attachment TTL sweeper');
49
+ }
50
+ return runtime;
51
+ }
52
+
53
+ /**
54
+ * Advertise one channel's bot workspaces to the global sweeper. The provider
55
+ * is consulted at sweep time, so bots added or removed later are always
56
+ * current. The default workspace is always included.
57
+ */
58
+ export function registerInboundTtlWorkspaces(ctx, service, {
59
+ workspaces,
60
+ configStore,
61
+ defaultWorkspace,
62
+ botIdFrom = (bot) => bot?.botId,
63
+ } = {}) {
64
+ if (!service || typeof service.registerWorkspaceProvider !== 'function') return undefined;
65
+ if (!workspaces || typeof workspaces.workspaceFor !== 'function'
66
+ || typeof defaultWorkspace !== 'string' || !defaultWorkspace) {
67
+ return undefined;
68
+ }
69
+ const unregister = service.registerWorkspaceProvider(() => {
70
+ const bots = typeof configStore?.list === 'function' ? configStore.list() : [];
71
+ const paths = [defaultWorkspace];
72
+ for (const bot of Array.isArray(bots) ? bots : []) {
73
+ try {
74
+ paths.push(workspaces.workspaceFor(botIdFrom(bot)));
75
+ } catch {
76
+ // A malformed config entry must not hide the remaining workspaces.
77
+ }
78
+ }
79
+ return paths;
80
+ });
81
+ if (typeof ctx?.effect === 'function') {
82
+ ctx.effect(() => unregister, 'dsh-im: unregister inbound TTL workspaces');
83
+ }
84
+ return unregister;
85
+ }
@@ -0,0 +1,179 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ import { sweepInboundAttachments } from '../../src/channels/shared/inbound-file.mjs';
5
+
6
+ const DEFAULT_SWEEP_INTERVAL_MS = 30 * 60_000;
7
+
8
+ // A tracked directory is shielded from sweeps while its turn may still be
9
+ // running. Aborted or stalled turns never call cleanup(), so protection has
10
+ // an upper bound — far above the default reply timeout — after which the
11
+ // directory falls back under TTL control instead of leaking forever.
12
+ const TRACKED_PROTECTION_MS = 24 * 60 * 60_000;
13
+
14
+ /**
15
+ * Host-process coordinator for the global inbound attachment TTL. The staged
16
+ * registry marks directories owned by in-flight turns so a slow turn can
17
+ * outlive any TTL window without the sweeper deleting its files.
18
+ */
19
+ export function createInboundTtlService({
20
+ store,
21
+ logger = console,
22
+ intervalMs = DEFAULT_SWEEP_INTERVAL_MS,
23
+ } = {}) {
24
+ if (!store || typeof store.getTtlHours !== 'function') {
25
+ throw new TypeError('createInboundTtlService requires a store exposing getTtlHours()');
26
+ }
27
+ const tracked = new Map();
28
+ const providers = new Set();
29
+ let timer = null;
30
+
31
+ const runSweep = () => {
32
+ sweepNow().catch((error) => {
33
+ logger.error?.('[dsh-im] inbound attachment sweep failed', error);
34
+ });
35
+ };
36
+
37
+ function registerWorkspaceProvider(provider) {
38
+ if (typeof provider !== 'function') {
39
+ throw new TypeError('inbound TTL workspace provider must be a function');
40
+ }
41
+ const wasEmpty = providers.size === 0;
42
+ providers.add(provider);
43
+ // The startup sweep races channel activation and usually runs before any
44
+ // provider exists; re-sweep on the first registration so crash leftovers
45
+ // do not wait a full interval.
46
+ if (wasEmpty && timer) runSweep();
47
+ return () => {
48
+ providers.delete(provider);
49
+ };
50
+ }
51
+
52
+ function stagingRetention() {
53
+ // TTL 0 deletes at turn end; every other mode keeps files for the sweeper.
54
+ return store.getTtlHours() === 0 ? 'turn' : 'persistent';
55
+ }
56
+
57
+ function canonicalPath(directory) {
58
+ try {
59
+ return realpathSync(directory);
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function trackDirectory(directory, { now = () => Date.now() } = {}) {
66
+ if (typeof directory !== 'string' || !directory) return;
67
+ const trackedAt = now();
68
+ tracked.set(directory, trackedAt);
69
+ // The harness-reported cwd and workspaceFor() may normalize symlinks
70
+ // differently; track both spellings so either side finds the entry.
71
+ const canonical = canonicalPath(directory);
72
+ if (canonical && canonical !== directory) tracked.set(canonical, trackedAt);
73
+ }
74
+
75
+ function releaseDirectory(directory) {
76
+ tracked.delete(directory);
77
+ const canonical = canonicalPath(directory);
78
+ if (canonical && canonical !== directory) tracked.delete(canonical);
79
+ }
80
+
81
+ function isTrackedNow(directory) {
82
+ const trackedAt = tracked.get(directory);
83
+ if (trackedAt === undefined) return false;
84
+ if (Date.now() - trackedAt >= TRACKED_PROTECTION_MS) {
85
+ tracked.delete(directory);
86
+ return false;
87
+ }
88
+ return true;
89
+ }
90
+
91
+ function trackStaged(staged) {
92
+ if (!staged || typeof staged !== 'object') return staged;
93
+ const { directory } = staged;
94
+ if (typeof directory !== 'string' || !directory) return staged;
95
+ trackDirectory(directory);
96
+ const cleanup = typeof staged.cleanup === 'function' ? staged.cleanup : null;
97
+ return Object.freeze({
98
+ ...staged,
99
+ async cleanup() {
100
+ try {
101
+ await cleanup?.call(staged);
102
+ } finally {
103
+ releaseDirectory(directory);
104
+ }
105
+ },
106
+ });
107
+ }
108
+
109
+ async function sweepNow() {
110
+ const ttlHours = store.getTtlHours();
111
+ // Keep-forever never deletes, so no workspace is even inspected.
112
+ if (ttlHours === -1) return { deletedDirectories: 0, sweptWorkspaces: 0 };
113
+ const workspaces = new Set();
114
+ for (const provider of providers) {
115
+ try {
116
+ const provided = await provider();
117
+ for (const workspace of Array.isArray(provided) ? provided : []) {
118
+ if (typeof workspace === 'string' && workspace) workspaces.add(resolve(workspace));
119
+ }
120
+ } catch (error) {
121
+ logger.warn?.('[dsh-im] inbound TTL workspace provider failed', error);
122
+ }
123
+ }
124
+ const isTracked = (directory) => isTrackedNow(directory)
125
+ || (canonicalPath(directory) !== null && isTrackedNow(canonicalPath(directory)));
126
+ let deletedDirectories = 0;
127
+ let sweptWorkspaces = 0;
128
+ for (const workspace of workspaces) {
129
+ try {
130
+ const result = await sweepInboundAttachments(workspace, ttlHours, { isTracked });
131
+ deletedDirectories += result.deleted;
132
+ sweptWorkspaces += 1;
133
+ } catch (error) {
134
+ logger.warn?.(`[dsh-im] inbound attachment sweep failed under ${workspace}`, error);
135
+ }
136
+ }
137
+ if (deletedDirectories > 0) {
138
+ logger.info?.(
139
+ `[dsh-im] inbound attachment sweep deleted ${deletedDirectories} directories`
140
+ + ` across ${sweptWorkspaces} workspaces (ttl ${ttlHours}h)`,
141
+ );
142
+ }
143
+ return { deletedDirectories, sweptWorkspaces };
144
+ }
145
+
146
+ function start() {
147
+ if (timer) return;
148
+ runSweep();
149
+ timer = setInterval(runSweep, intervalMs);
150
+ timer.unref?.();
151
+ }
152
+
153
+ // A freshly saved TTL postpones the next scheduled sweep by a full
154
+ // interval so the new value is never enforced by a sweep firing moments
155
+ // after the change, and is not starved of one either.
156
+ function resetSweepSchedule() {
157
+ if (!timer) return;
158
+ clearInterval(timer);
159
+ timer = setInterval(runSweep, intervalMs);
160
+ timer.unref?.();
161
+ }
162
+
163
+ function stop() {
164
+ if (!timer) return;
165
+ clearInterval(timer);
166
+ timer = null;
167
+ }
168
+
169
+ return Object.freeze({
170
+ registerWorkspaceProvider,
171
+ stagingRetention,
172
+ trackDirectory,
173
+ trackStaged,
174
+ sweepNow,
175
+ start,
176
+ resetSweepSchedule,
177
+ stop,
178
+ });
179
+ }
@@ -13,6 +13,7 @@ import { setImHostLanguage } from '../../src/channels/shared/i18n.mjs';
13
13
  import { installDeliveryRpc } from './delivery-rpc.mjs';
14
14
  import { installDeliveryHttp } from './delivery-http.mjs';
15
15
  import { createDeliveryService } from './delivery-service.mjs';
16
+ import { installInboundTtlRpc } from './inbound-ttl-rpc.mjs';
16
17
  import { installUpdateRpc } from './update-rpc.mjs';
17
18
 
18
19
  export const name = 'dsh-im-host';
@@ -32,6 +33,7 @@ function channelConfig(config, name, deliveryService) {
32
33
 
33
34
  export function createImHostPlugin(internals = {}) {
34
35
  const startUpdate = internals.installUpdateRpc ?? installUpdateRpc;
36
+ const startInboundTtl = internals.installInboundTtlRpc ?? installInboundTtlRpc;
35
37
  const startDelivery = internals.installDeliveryRpc ?? installDeliveryRpc;
36
38
  const startDeliveryHttp = internals.installDeliveryHttp ?? installDeliveryHttp;
37
39
  const makeDeliveryService = internals.createDeliveryService ?? createDeliveryService;
@@ -68,6 +70,7 @@ export function createImHostPlugin(internals = {}) {
68
70
  deliveryService.send(botId, targetId, text, options)
69
71
  ),
70
72
  listTargets: async (botId) => (await deliveryService.listTargets(botId)).targets,
73
+ listBots: () => deliveryService.listBots(),
71
74
  }));
72
75
  }
73
76
  const activate = async (readyCtx) => {
@@ -109,6 +112,11 @@ export function createImHostPlugin(internals = {}) {
109
112
  } catch (error) {
110
113
  logger.error?.('[dsh-im] failed to activate update management; continuing with channels', error);
111
114
  }
115
+ try {
116
+ startInboundTtl(ctx, { config });
117
+ } catch (error) {
118
+ logger.error?.('[dsh-im] failed to activate inbound TTL settings; continuing with channels', error);
119
+ }
112
120
  try {
113
121
  startDelivery(ctx, deliveryService, { authority: config.rpcAuthority });
114
122
  } catch (error) {
@@ -52,6 +52,15 @@ function questionError(message, code) {
52
52
  return error;
53
53
  }
54
54
 
55
+ function sessionEvents(session) {
56
+ if (typeof session?.snapshotEvents === 'function') {
57
+ const events = session.snapshotEvents();
58
+ if (Array.isArray(events)) return events;
59
+ }
60
+ const events = session?.events;
61
+ return Array.isArray(events) ? events : null;
62
+ }
63
+
55
64
  function matchesQuestions(value, pending) {
56
65
  if (!value || typeof value !== 'object'
57
66
  || value.sessionId !== pending.sessionId
@@ -191,6 +200,7 @@ class ModernHarnessApi {
191
200
  #pendingQuestions = new Map();
192
201
  #pendingApprovals = new Map();
193
202
  #sessionCursors = new Map();
203
+ #assistantStreams = new Map();
194
204
  #disposers = [];
195
205
  #disposed = false;
196
206
 
@@ -252,6 +262,13 @@ class ModernHarnessApi {
252
262
  if (Number.isSafeInteger(event.seq)) this.#rememberCursor(sessionId, event.seq);
253
263
  this.#broadcast({ type: 'session/event', sessionId, event });
254
264
  }, { global: true }));
265
+ this.#disposers.push(ctx.on('agent/assistant-stream', ({ agent, frame }) => {
266
+ this.#acceptAssistantStream(agent, frame);
267
+ }, { global: true }));
268
+ this.#disposers.push(ctx.on('agent/disposed', ({ agent }) => {
269
+ const sessionId = agent?.session?.id;
270
+ if (typeof sessionId === 'string') this.#assistantStreams.delete(sessionId);
271
+ }, { global: true }));
255
272
  this.#disposers.push(ctx.on(
256
273
  'approval/request',
257
274
  (request, next) => this.#requestApproval(request, next),
@@ -319,7 +336,7 @@ class ModernHarnessApi {
319
336
  cursor = this.#sessionCursors.get(sessionId) ?? snapshot.cursor;
320
337
  if (beforeSeq === undefined && cursor === snapshot.cursor) {
321
338
  return {
322
- events: historyEntries(snapshot.records),
339
+ events: this.#withAssistantStream(sessionId, historyEntries(snapshot.records)),
323
340
  hasMore: snapshot.hasMore === true,
324
341
  ...(snapshot.projections === undefined ? {} : { projections: snapshot.projections }),
325
342
  };
@@ -333,7 +350,54 @@ class ModernHarnessApi {
333
350
  ...(beforeSeq === undefined ? {} : { beforeSeq }),
334
351
  },
335
352
  }, signal);
336
- return { events: historyEntries(page.records), hasMore: page.hasMore === true };
353
+ return {
354
+ events: beforeSeq === undefined
355
+ ? this.#withAssistantStream(sessionId, historyEntries(page.records))
356
+ : historyEntries(page.records),
357
+ hasMore: page.hasMore === true,
358
+ };
359
+ }
360
+
361
+ #acceptAssistantStream(agent, frame) {
362
+ const sessionId = agent?.session?.id;
363
+ if (typeof sessionId !== 'string' || !frame || typeof frame !== 'object') return;
364
+ if (frame.type === 'start') {
365
+ const nextSeq = agent?.session?.seq;
366
+ const startedAfterSeq = Number.isSafeInteger(nextSeq) && nextSeq >= 0
367
+ ? nextSeq - 1
368
+ : this.#sessionCursors.get(sessionId) ?? -1;
369
+ this.#assistantStreams.set(sessionId, {
370
+ attemptId: frame.attemptId,
371
+ turn: frame.turn,
372
+ step: frame.step,
373
+ startedAfterSeq,
374
+ entries: [],
375
+ });
376
+ return;
377
+ }
378
+ const stream = this.#assistantStreams.get(sessionId);
379
+ if (!stream || stream.attemptId !== frame.attemptId) return;
380
+ if (frame.type === 'end') {
381
+ this.#assistantStreams.delete(sessionId);
382
+ return;
383
+ }
384
+ if (frame.type !== 'chunk' || frame.index !== stream.entries.length) return;
385
+ stream.entries.push({
386
+ type: 'transient',
387
+ event: {
388
+ type: 'assistant/chunk',
389
+ // Match DSH's Web client ordering: live chunks sit in the numeric gap
390
+ // before the durable assistant/message that replaces them.
391
+ seq: stream.startedAfterSeq + 1 - 1 / (frame.index + 2),
392
+ time: frame.time,
393
+ data: { turn: stream.turn, step: stream.step, chunk: frame.chunk },
394
+ },
395
+ });
396
+ }
397
+
398
+ #withAssistantStream(sessionId, entries) {
399
+ const transient = this.#assistantStreams.get(sessionId)?.entries ?? [];
400
+ return transient.length === 0 ? entries : [...entries, ...transient];
337
401
  }
338
402
 
339
403
  #rememberCursor(sessionId, cursor) {
@@ -385,15 +449,15 @@ class ModernHarnessApi {
385
449
  }
386
450
 
387
451
  #claimableAgent(agent) {
388
- const sessionId = agent?.session?.id ?? agent?.id;
389
- if (typeof sessionId !== 'string' || !agent?.session || !Array.isArray(agent.session.events)) {
390
- return null;
391
- }
452
+ const session = agent?.session;
453
+ const sessionId = session?.id ?? agent?.id;
454
+ const events = sessionEvents(session);
455
+ if (typeof sessionId !== 'string' || !events) return null;
392
456
  return hasActiveHarnessInteractionOwner(
393
457
  this.#scope,
394
458
  sessionId,
395
- agent.session.events,
396
- ) ? { sessionId, session: agent.session } : null;
459
+ events,
460
+ ) ? { sessionId, events } : null;
397
461
  }
398
462
 
399
463
  #questionFrame(pending) {
@@ -464,8 +528,8 @@ class ModernHarnessApi {
464
528
  const claimed = new Set([...this.#pendingApprovals.values()].map((entry) => entry.approvalId));
465
529
  const decided = new Set();
466
530
  let approvalId;
467
- for (let index = owner.session.events.length - 1; index >= 0; index -= 1) {
468
- const event = owner.session.events[index];
531
+ for (let index = owner.events.length - 1; index >= 0; index -= 1) {
532
+ const event = owner.events[index];
469
533
  if (event.type === 'approval/decided') {
470
534
  decided.add(event.data?.id);
471
535
  } else if (event.type === 'approval/asked') {
@@ -547,6 +611,7 @@ class ModernHarnessApi {
547
611
  if (this.#disposed) return;
548
612
  this.#disposed = true;
549
613
  for (const subscription of [...this.#mux]) subscription.close();
614
+ this.#assistantStreams.clear();
550
615
  for (const pending of [...this.#pendingApprovals.values()]) pending.settle('cancelled');
551
616
  for (const pending of [...this.#pendingQuestions.values()]) pending.settle(
552
617
  'cancelled',