@tivility/dsh-session-share 0.1.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/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # @tivility/dsh-session-share
2
+
3
+ A link that opens one session.
4
+
5
+ ## The gap this fills
6
+
7
+ The harness's GUI opens wherever it was left. There is no URL that means *this
8
+ conversation* — the client carries **no routing at all** (search it for
9
+ `pushState`, `location.hash`, or `searchParams` and you find nothing), and the
10
+ selected session is restored from persisted state on load.
11
+
12
+ So a session cannot be pointed at. Not in a message, not in a ticket, not by
13
+ the agent describing its own work. "It's the third one down, from Tuesday" is
14
+ the state of the art.
15
+
16
+ This adds the one missing step:
17
+
18
+ ```
19
+ http://127.0.0.1:3080/?session=<id> ← opens that session
20
+ http://127.0.0.1:3080/s/<id> ← redirects to the same place
21
+ ```
22
+
23
+ The agent is taught the format, so it can hand out a link to the conversation
24
+ you are in, to a subagent it started, or to a session it forked.
25
+
26
+ ## What it does not do
27
+
28
+ **It shares nothing and hides nothing.** The sidebar, the other sessions, and
29
+ the composer are exactly as they would be. The link decides where a visitor
30
+ lands, not what they can see.
31
+
32
+ That is a deliberate limit rather than an oversight. Hiding the sidebar would
33
+ *look* like isolation while `/api` went on answering every question about every
34
+ other session — visual privacy, which is the kind that gets people in trouble.
35
+ Restricting what a visitor can reach is
36
+ [`@tivility/dsh-readonly-auth`](../readonly-auth)'s job, and it does it at the
37
+ API rather than in the DOM.
38
+
39
+ ## How it works
40
+
41
+ Two halves, which is what the harness's client-module system is for.
42
+
43
+ **The browser half** reads `?session=`, waits for the session list, calls
44
+ `sessions.open(id)`, then strips the parameter from the address bar.
45
+
46
+ Two details carry their weight:
47
+
48
+ - **The wait is on the list's phase, not a timeout.** `open()` fails loud on an
49
+ id the list does not carry, and the list arrives asynchronously — so `phase:
50
+ 'ready'` is what separates "not loaded yet" from "no such session". A timeout
51
+ would be guessing.
52
+ - **The parameter is consumed once.** The selection is persisted, so leaving it
53
+ in the URL would make every later reload jump back to the linked session,
54
+ fighting the user each time they navigate away and refresh. Consuming it
55
+ makes the link mean "start here", which is what a link means.
56
+
57
+ **The node half** serves the `/s/<id>` redirect and registers the prompt
58
+ section. The short form has to be a redirect rather than a page: the GUI is
59
+ served only at the root — the harness's static fallback answers `/` and
60
+ `/index.html` and 404s everything else, with no SPA rewrite — so `/s/<id>`
61
+ would otherwise be a 404. The id is validated before it becomes a `Location`,
62
+ because an unconstrained value in a response header is where header injection
63
+ lives.
64
+
65
+ ## Building the browser bundle
66
+
67
+ This is the only package here with a browser half, so it is the only one that
68
+ needs a bundler:
69
+
70
+ ```sh
71
+ pnpm --filter @tivility/dsh-session-share run bundle
72
+ ```
73
+
74
+ after the usual `pnpm run build`. The bundle must exist before the harness
75
+ loads the plugin; without it the client-module scanner reports a missing
76
+ bundle at startup.
77
+
78
+ `tsdown.config.ts` restates the harness's loader contract — CJS on a browser
79
+ platform, the `__ModuleLoader__.load` handoff, externals limited to the
80
+ loader's module table, source maps. The harness ships a preset for this, but it
81
+ is repository-bound (it locates packages by globbing inside the harness
82
+ checkout), so the four rules are written out instead. They are short, and they
83
+ are the whole of what makes a bundle loadable.
84
+
85
+ ## Configuration
86
+
87
+ ```yaml
88
+ - id: session-share
89
+ name: '@tivility/dsh-session-share'
90
+ config:
91
+ route: /s # empty mounts no redirect, leaving the query form
92
+ prompt: true # teach the model the link format
93
+ trustedHosts: []
94
+ ```
95
+
96
+ The `?session=` parameter name is a constant, not configuration: the two halves
97
+ cannot share a configured value — the browser bundle is fetched outside the
98
+ loader's config tree — and a constant is the honest form of something that has
99
+ to match in two places.
100
+
101
+ ## License
102
+
103
+ MIT
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The browser half: open the session a link names.
3
+ *
4
+ * The harness's GUI has no routing — the whole client is searched in vain for
5
+ * `pushState`, `location.hash`, or `searchParams`. The selected session lives
6
+ * in the sessions manager and is restored from persisted state on load, so a
7
+ * URL naming one has nowhere to be read. This adds that one step and nothing
8
+ * else: read the parameter, select the session, get out of the way.
9
+ *
10
+ * It deliberately hides nothing. The sidebar, the other sessions, and the
11
+ * composer are all exactly as they would be — the link decides where you land,
12
+ * not what you can see.
13
+ *
14
+ * ## Why the context is typed here
15
+ *
16
+ * The two halves of this package compile in one TypeScript program, and the
17
+ * node half pulls in the harness's HOST augmentations of `Context` — where
18
+ * `sessions` is a different service with the same name. Importing
19
+ * `ClientContext` would therefore resolve `ctx.sessions` to the host's shape.
20
+ * The harness avoids this with separate client and host programs; a two-file
21
+ * package states the three members it uses instead, which also leaves this
22
+ * half with no harness type dependency at all.
23
+ * @module @tivility/dsh-session-share/client
24
+ */
25
+ import { SESSION_PARAM } from '../param.js';
26
+ /** Client-side services this plugin drives. */
27
+ export const inject = ['sessions'];
28
+ /**
29
+ * Remove the parameter from the address bar once it has been acted on.
30
+ *
31
+ * The selection is persisted by the runtime, so leaving the parameter in place
32
+ * would make every later reload jump back to the linked session — fighting the
33
+ * user each time they navigate away and refresh. Consuming it once makes the
34
+ * link mean "start here", which is what a link means.
35
+ */
36
+ function stripParam() {
37
+ const url = new URL(window.location.href);
38
+ if (!url.searchParams.has(SESSION_PARAM))
39
+ return;
40
+ url.searchParams.delete(SESSION_PARAM);
41
+ window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`);
42
+ }
43
+ /**
44
+ * Watch the session list until the named session can be opened.
45
+ * @param ctx - the client root context.
46
+ */
47
+ export function apply(ctx) {
48
+ const requested = new URL(window.location.href).searchParams.get(SESSION_PARAM);
49
+ // No parameter is the overwhelmingly common case — every ordinary visit — so
50
+ // this plugin costs nothing at all when it has nothing to do.
51
+ if (requested === null || requested === '')
52
+ return;
53
+ ctx.effect(() => {
54
+ let done = false;
55
+ const attempt = () => {
56
+ if (done)
57
+ return;
58
+ const state = ctx.sessions.list.getSnapshot();
59
+ if (state.byId[requested] !== undefined) {
60
+ done = true;
61
+ ctx.sessions.open(requested);
62
+ stripParam();
63
+ return;
64
+ }
65
+ // The list arrives asynchronously, so an id that is absent right now may
66
+ // simply not have loaded. The phase is what separates "not yet" from "no
67
+ // such session" — a timeout would guess at it.
68
+ if (state.phase === 'ready') {
69
+ done = true;
70
+ stripParam();
71
+ ctx.logger?.warn?.(`session-share: no session ${requested} in this harness`);
72
+ }
73
+ };
74
+ const unsubscribe = ctx.sessions.list.subscribe(attempt);
75
+ // The list may already be ready before this plugin activates, in which case
76
+ // no change is ever published and the subscription alone would wait forever.
77
+ attempt();
78
+ return unsubscribe;
79
+ });
80
+ }
81
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/client/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AA8B3C,+CAA+C;AAC/C,MAAM,CAAC,MAAM,MAAM,GAAG,CAAC,UAAU,CAAC,CAAA;AAElC;;;;;;;GAOG;AACH,SAAS,UAAU;IACjB,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAA;IACzC,IAAI,CAAC,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,aAAa,CAAC;QAAE,OAAM;IAChD,GAAG,CAAC,YAAY,CAAC,MAAM,CAAC,aAAa,CAAC,CAAA;IACtC,MAAM,CAAC,OAAO,CAAC,YAAY,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,EAAE,GAAG,GAAG,CAAC,QAAQ,GAAG,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC,CAAA;AAClG,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,KAAK,CAAC,GAAiB;IACrC,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,YAAY,CAAC,GAAG,CAAC,aAAa,CAAC,CAAA;IAC/E,6EAA6E;IAC7E,8DAA8D;IAC9D,IAAI,SAAS,KAAK,IAAI,IAAI,SAAS,KAAK,EAAE;QAAE,OAAM;IAElD,GAAG,CAAC,MAAM,CAAC,GAAG,EAAE;QACd,IAAI,IAAI,GAAG,KAAK,CAAA;QAChB,MAAM,OAAO,GAAG,GAAS,EAAE;YACzB,IAAI,IAAI;gBAAE,OAAM;YAChB,MAAM,KAAK,GAAG,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,WAAW,EAAE,CAAA;YAC7C,IAAI,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,SAAS,EAAE,CAAC;gBACxC,IAAI,GAAG,IAAI,CAAA;gBACX,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;gBAC5B,UAAU,EAAE,CAAA;gBACZ,OAAM;YACR,CAAC;YACD,yEAAyE;YACzE,yEAAyE;YACzE,+CAA+C;YAC/C,IAAI,KAAK,CAAC,KAAK,KAAK,OAAO,EAAE,CAAC;gBAC5B,IAAI,GAAG,IAAI,CAAA;gBACX,UAAU,EAAE,CAAA;gBACZ,GAAG,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,6BAA6B,SAAS,kBAAkB,CAAC,CAAA;YAC9E,CAAC;QACH,CAAC,CAAA;QACD,MAAM,WAAW,GAAG,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAA;QACxD,4EAA4E;QAC5E,6EAA6E;QAC7E,OAAO,EAAE,CAAA;QACT,OAAO,WAAW,CAAA;IACpB,CAAC,CAAC,CAAA;AACJ,CAAC"}
package/lib/client.js ADDED
@@ -0,0 +1,96 @@
1
+ window.__ModuleLoader__.load({ id: "@tivility/dsh-session-share", factory: (require) => {
2
+ var module = { exports: {} }; var exports = module.exports;
3
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
4
+
5
+ //#region lib/param.js
6
+ /**
7
+ * The one fact both halves of this plugin have to agree on.
8
+ *
9
+ * The query parameter is a constant rather than configuration because the two
10
+ * halves cannot share a configuration value: the browser bundle is fetched
11
+ * outside the loader's config tree and receives only what its own `apply`
12
+ * is handed, while the link the host writes into the system prompt is built
13
+ * on the other side of the wire. A constant is the honest form of a value
14
+ * that must match in two places.
15
+ * @module @tivility/dsh-session-share/param
16
+ */
17
+ /** Query parameter naming the session to open on load. */
18
+ const SESSION_PARAM = "session";
19
+
20
+ //#endregion
21
+ //#region lib/client/index.js
22
+ /**
23
+ * The browser half: open the session a link names.
24
+ *
25
+ * The harness's GUI has no routing — the whole client is searched in vain for
26
+ * `pushState`, `location.hash`, or `searchParams`. The selected session lives
27
+ * in the sessions manager and is restored from persisted state on load, so a
28
+ * URL naming one has nowhere to be read. This adds that one step and nothing
29
+ * else: read the parameter, select the session, get out of the way.
30
+ *
31
+ * It deliberately hides nothing. The sidebar, the other sessions, and the
32
+ * composer are all exactly as they would be — the link decides where you land,
33
+ * not what you can see.
34
+ *
35
+ * ## Why the context is typed here
36
+ *
37
+ * The two halves of this package compile in one TypeScript program, and the
38
+ * node half pulls in the harness's HOST augmentations of `Context` — where
39
+ * `sessions` is a different service with the same name. Importing
40
+ * `ClientContext` would therefore resolve `ctx.sessions` to the host's shape.
41
+ * The harness avoids this with separate client and host programs; a two-file
42
+ * package states the three members it uses instead, which also leaves this
43
+ * half with no harness type dependency at all.
44
+ * @module @tivility/dsh-session-share/client
45
+ */
46
+ /** Client-side services this plugin drives. */
47
+ const inject = ["sessions"];
48
+ /**
49
+ * Remove the parameter from the address bar once it has been acted on.
50
+ *
51
+ * The selection is persisted by the runtime, so leaving the parameter in place
52
+ * would make every later reload jump back to the linked session — fighting the
53
+ * user each time they navigate away and refresh. Consuming it once makes the
54
+ * link mean "start here", which is what a link means.
55
+ */
56
+ function stripParam() {
57
+ const url = new URL(window.location.href);
58
+ if (!url.searchParams.has(SESSION_PARAM)) return;
59
+ url.searchParams.delete(SESSION_PARAM);
60
+ window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`);
61
+ }
62
+ /**
63
+ * Watch the session list until the named session can be opened.
64
+ * @param ctx - the client root context.
65
+ */
66
+ function apply(ctx) {
67
+ const requested = new URL(window.location.href).searchParams.get(SESSION_PARAM);
68
+ if (requested === null || requested === "") return;
69
+ ctx.effect(() => {
70
+ let done = false;
71
+ const attempt = () => {
72
+ if (done) return;
73
+ const state = ctx.sessions.list.getSnapshot();
74
+ if (state.byId[requested] !== void 0) {
75
+ done = true;
76
+ ctx.sessions.open(requested);
77
+ stripParam();
78
+ return;
79
+ }
80
+ if (state.phase === "ready") {
81
+ done = true;
82
+ stripParam();
83
+ ctx.logger?.warn?.(`session-share: no session ${requested} in this harness`);
84
+ }
85
+ };
86
+ const unsubscribe = ctx.sessions.list.subscribe(attempt);
87
+ attempt();
88
+ return unsubscribe;
89
+ });
90
+ }
91
+
92
+ //#endregion
93
+ exports.apply = apply;
94
+ exports.inject = inject;
95
+ return module.exports; } });
96
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","names":[],"sources":["param.js","client/index.js"],"sourcesContent":["/**\n * The one fact both halves of this plugin have to agree on.\n *\n * The query parameter is a constant rather than configuration because the two\n * halves cannot share a configuration value: the browser bundle is fetched\n * outside the loader's config tree and receives only what its own `apply`\n * is handed, while the link the host writes into the system prompt is built\n * on the other side of the wire. A constant is the honest form of a value\n * that must match in two places.\n * @module @tivility/dsh-session-share/param\n */\n/** Query parameter naming the session to open on load. */\nexport const SESSION_PARAM = 'session';\n/**\n * Build the query a share link carries.\n * @param sessionId - the session to open.\n * @returns the query string, question mark included.\n */\nexport function shareQuery(sessionId) {\n return `?${SESSION_PARAM}=${encodeURIComponent(sessionId)}`;\n}\n//# sourceMappingURL=param.js.map","/**\n * The browser half: open the session a link names.\n *\n * The harness's GUI has no routing — the whole client is searched in vain for\n * `pushState`, `location.hash`, or `searchParams`. The selected session lives\n * in the sessions manager and is restored from persisted state on load, so a\n * URL naming one has nowhere to be read. This adds that one step and nothing\n * else: read the parameter, select the session, get out of the way.\n *\n * It deliberately hides nothing. The sidebar, the other sessions, and the\n * composer are all exactly as they would be — the link decides where you land,\n * not what you can see.\n *\n * ## Why the context is typed here\n *\n * The two halves of this package compile in one TypeScript program, and the\n * node half pulls in the harness's HOST augmentations of `Context` — where\n * `sessions` is a different service with the same name. Importing\n * `ClientContext` would therefore resolve `ctx.sessions` to the host's shape.\n * The harness avoids this with separate client and host programs; a two-file\n * package states the three members it uses instead, which also leaves this\n * half with no harness type dependency at all.\n * @module @tivility/dsh-session-share/client\n */\nimport { SESSION_PARAM } from '../param.js';\n/** Client-side services this plugin drives. */\nexport const inject = ['sessions'];\n/**\n * Remove the parameter from the address bar once it has been acted on.\n *\n * The selection is persisted by the runtime, so leaving the parameter in place\n * would make every later reload jump back to the linked session — fighting the\n * user each time they navigate away and refresh. Consuming it once makes the\n * link mean \"start here\", which is what a link means.\n */\nfunction stripParam() {\n const url = new URL(window.location.href);\n if (!url.searchParams.has(SESSION_PARAM))\n return;\n url.searchParams.delete(SESSION_PARAM);\n window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`);\n}\n/**\n * Watch the session list until the named session can be opened.\n * @param ctx - the client root context.\n */\nexport function apply(ctx) {\n const requested = new URL(window.location.href).searchParams.get(SESSION_PARAM);\n // No parameter is the overwhelmingly common case — every ordinary visit — so\n // this plugin costs nothing at all when it has nothing to do.\n if (requested === null || requested === '')\n return;\n ctx.effect(() => {\n let done = false;\n const attempt = () => {\n if (done)\n return;\n const state = ctx.sessions.list.getSnapshot();\n if (state.byId[requested] !== undefined) {\n done = true;\n ctx.sessions.open(requested);\n stripParam();\n return;\n }\n // The list arrives asynchronously, so an id that is absent right now may\n // simply not have loaded. The phase is what separates \"not yet\" from \"no\n // such session\" — a timeout would guess at it.\n if (state.phase === 'ready') {\n done = true;\n stripParam();\n ctx.logger?.warn?.(`session-share: no session ${requested} in this harness`);\n }\n };\n const unsubscribe = ctx.sessions.list.subscribe(attempt);\n // The list may already be ready before this plugin activates, in which case\n // no change is ever published and the subscription alone would wait forever.\n attempt();\n return unsubscribe;\n });\n}\n//# sourceMappingURL=index.js.map"],"mappings":";;;;;;;;;;;;;;;;;AAYA,MAAa,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACc7B,MAAa,SAAS,CAAC,WAAW;;;;;;;;;AASlC,SAAS,aAAa;CAClB,MAAM,MAAM,IAAI,IAAI,OAAO,SAAS,KAAK;AACzC,KAAI,CAAC,IAAI,aAAa,IAAI,cAAc,CACpC;AACJ,KAAI,aAAa,OAAO,cAAc;AACtC,QAAO,QAAQ,aAAa,OAAO,QAAQ,OAAO,IAAI,GAAG,IAAI,WAAW,IAAI,SAAS,IAAI,OAAO;;;;;;AAMpG,SAAgB,MAAM,KAAK;CACvB,MAAM,YAAY,IAAI,IAAI,OAAO,SAAS,KAAK,CAAC,aAAa,IAAI,cAAc;AAG/E,KAAI,cAAc,QAAQ,cAAc,GACpC;AACJ,KAAI,aAAa;EACb,IAAI,OAAO;EACX,MAAM,gBAAgB;AAClB,OAAI,KACA;GACJ,MAAM,QAAQ,IAAI,SAAS,KAAK,aAAa;AAC7C,OAAI,MAAM,KAAK,eAAe,QAAW;AACrC,WAAO;AACP,QAAI,SAAS,KAAK,UAAU;AAC5B,gBAAY;AACZ;;AAKJ,OAAI,MAAM,UAAU,SAAS;AACzB,WAAO;AACP,gBAAY;AACZ,QAAI,QAAQ,OAAO,6BAA6B,UAAU,kBAAkB;;;EAGpF,MAAM,cAAc,IAAI,SAAS,KAAK,UAAU,QAAQ;AAGxD,WAAS;AACT,SAAO;GACT"}
package/lib/index.js ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Link straight to one session.
3
+ *
4
+ * The harness's GUI opens wherever it was left. There is no URL that means
5
+ * "this conversation" — the whole client carries no routing at all, and the
6
+ * selected session is restored from persisted state. So a session cannot be
7
+ * pointed at: not in a message, not in a ticket, not by the agent describing
8
+ * its own work.
9
+ *
10
+ * This plugin adds the one missing step. The browser half reads a `?session=`
11
+ * parameter and selects that session; this half serves a short redirect route
12
+ * and teaches the model the link format so it can hand out links to the
13
+ * conversations it starts.
14
+ *
15
+ * **It shares nothing.** Every session stays visible and the GUI is otherwise
16
+ * untouched — the link decides where a visitor lands, not what they can see.
17
+ * Restricting that is what `@tivility/dsh-readonly-auth` is for, and this
18
+ * plugin deliberately does not pretend to do it: hiding the sidebar would look
19
+ * like isolation while `/api` still answered every question about every other
20
+ * session.
21
+ * @module @tivility/dsh-session-share
22
+ */
23
+ import z from '@deepseek-ai/schemastery';
24
+ import { absoluteUrl, isTrustedRequest, sendStatus } from '@tivility/dsh-web-kit';
25
+ import { SESSION_PARAM, shareQuery } from './param.js';
26
+ export { SESSION_PARAM, shareQuery } from './param.js';
27
+ /** Stable Cordis plugin name. */
28
+ export const name = 'session-share';
29
+ /** The HTTP carrier this plugin mounts its redirect on. */
30
+ export const inject = ['webServer'];
31
+ export const Config = z.object({
32
+ route: z.string().default('/s'),
33
+ prompt: z.boolean().default(true),
34
+ trustedHosts: z.array(String).default([]),
35
+ });
36
+ /** A session id as it may appear in a URL: the harness mints uuids. */
37
+ const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
38
+ /**
39
+ * Build the redirect handler for the short link form.
40
+ *
41
+ * A redirect rather than a page, because the GUI is a single-page app served
42
+ * only at the root: the harness's static fallback serves `index.html` for `/`
43
+ * and `/index.html` and 404s everything else, with no SPA rewrite. `/s/<id>`
44
+ * would therefore be a 404, so the short form bounces to the query form the
45
+ * browser half reads.
46
+ * @param route - the configured prefix.
47
+ * @param trustedHosts - authorities the fence accepts.
48
+ * @returns the route handler.
49
+ */
50
+ function createHandler(route, trustedHosts) {
51
+ return (req, res) => {
52
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
53
+ sendStatus(res, 405, 'method not allowed', { allow: 'GET, HEAD' });
54
+ return;
55
+ }
56
+ if (!isTrustedRequest(req, trustedHosts)) {
57
+ sendStatus(res, 403, 'forbidden');
58
+ return;
59
+ }
60
+ /* v8 ignore next -- node:http always sets url on server requests */
61
+ const pathname = new URL(req.url ?? '/', 'http://x').pathname;
62
+ let id;
63
+ try {
64
+ id = decodeURIComponent(pathname.slice(route.length).replace(/^\//, ''));
65
+ }
66
+ catch {
67
+ sendStatus(res, 400, 'malformed session id');
68
+ return;
69
+ }
70
+ // Validated before it becomes a Location: the value lands in a header, and
71
+ // an unconstrained one is where header injection lives.
72
+ if (!SESSION_ID.test(id)) {
73
+ sendStatus(res, 400, 'malformed session id');
74
+ return;
75
+ }
76
+ res.writeHead(302, { location: `/${shareQuery(id)}` });
77
+ res.end();
78
+ };
79
+ }
80
+ /**
81
+ * The system prompt section teaching the model this link format.
82
+ * @param ctx - a context carrying the `webServer` service.
83
+ * @param route - the configured short-link prefix.
84
+ * @param sessionId - the session the calling agent belongs to.
85
+ * @returns the prompt text.
86
+ */
87
+ function promptSection(ctx, route, sessionId) {
88
+ const base = absoluteUrl(ctx, '/');
89
+ const link = `${base}${shareQuery(sessionId)}`;
90
+ const shortForm = route === ''
91
+ ? ''
92
+ : ` The shorter ${absoluteUrl(ctx, route)}/${sessionId} redirects to the same place.`;
93
+ return [
94
+ `This conversation has a direct link: ${link}`,
95
+ `Opening it in a browser selects this session instead of whatever was last open.${shortForm}`,
96
+ 'Give it to the user when they need to come back to this conversation, point someone at it,',
97
+ 'or file it somewhere. Any session id works in the same shape — a subagent you started,',
98
+ 'a session you forked — so a link can point at that one instead of this one.',
99
+ 'The link decides which session opens, not who may read it: everyone who can reach the',
100
+ 'harness sees the same thing they would see without it.',
101
+ ].join(' ');
102
+ }
103
+ /**
104
+ * Mount the redirect route and the prompt section.
105
+ * @param ctx - plugin context carrying the injected `webServer` service.
106
+ * @param config - plugin configuration.
107
+ */
108
+ export function apply(ctx, config = {}) {
109
+ const route = config.route ?? '/s';
110
+ const trustedHosts = config.trustedHosts ?? [];
111
+ if (route !== '') {
112
+ if (!route.startsWith('/') || route.endsWith('/')) {
113
+ throw new Error(`session-share: route ${JSON.stringify(route)} must be an absolute path without a trailing slash`);
114
+ }
115
+ ctx.effect(() => ctx.webServer.register({
116
+ kind: 'prefix',
117
+ path: route,
118
+ handler: createHandler(route, trustedHosts),
119
+ }), `session-share: ${route} route`);
120
+ }
121
+ if (config.prompt === false)
122
+ return;
123
+ ctx.inject(['systemPrompt'], (scope) => {
124
+ scope.systemPrompt.section({
125
+ name: 'session-share',
126
+ order: 91,
127
+ text: (context) => {
128
+ // Resolved per assembly: the port is known only after the server binds,
129
+ // and the session id is whichever agent this prompt is being built for.
130
+ const sessionId = context.agent?.session.id;
131
+ return sessionId === undefined ? '' : promptSection(scope, route, String(sessionId));
132
+ },
133
+ });
134
+ });
135
+ }
136
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/lib/param.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The one fact both halves of this plugin have to agree on.
3
+ *
4
+ * The query parameter is a constant rather than configuration because the two
5
+ * halves cannot share a configuration value: the browser bundle is fetched
6
+ * outside the loader's config tree and receives only what its own `apply`
7
+ * is handed, while the link the host writes into the system prompt is built
8
+ * on the other side of the wire. A constant is the honest form of a value
9
+ * that must match in two places.
10
+ * @module @tivility/dsh-session-share/param
11
+ */
12
+ /** Query parameter naming the session to open on load. */
13
+ export const SESSION_PARAM = 'session';
14
+ /**
15
+ * Build the query a share link carries.
16
+ * @param sessionId - the session to open.
17
+ * @returns the query string, question mark included.
18
+ */
19
+ export function shareQuery(sessionId) {
20
+ return `?${SESSION_PARAM}=${encodeURIComponent(sessionId)}`;
21
+ }
22
+ //# sourceMappingURL=param.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"param.js","sourceRoot":"","sources":["../src/param.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,0DAA0D;AAC1D,MAAM,CAAC,MAAM,aAAa,GAAG,SAAS,CAAA;AAEtC;;;;GAIG;AACH,MAAM,UAAU,UAAU,CAAC,SAAiB;IAC1C,OAAO,IAAI,aAAa,IAAI,kBAAkB,CAAC,SAAS,CAAC,EAAE,CAAA;AAC7D,CAAC"}
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The browser half: open the session a link names.
3
+ *
4
+ * The harness's GUI has no routing — the whole client is searched in vain for
5
+ * `pushState`, `location.hash`, or `searchParams`. The selected session lives
6
+ * in the sessions manager and is restored from persisted state on load, so a
7
+ * URL naming one has nowhere to be read. This adds that one step and nothing
8
+ * else: read the parameter, select the session, get out of the way.
9
+ *
10
+ * It deliberately hides nothing. The sidebar, the other sessions, and the
11
+ * composer are all exactly as they would be — the link decides where you land,
12
+ * not what you can see.
13
+ *
14
+ * ## Why the context is typed here
15
+ *
16
+ * The two halves of this package compile in one TypeScript program, and the
17
+ * node half pulls in the harness's HOST augmentations of `Context` — where
18
+ * `sessions` is a different service with the same name. Importing
19
+ * `ClientContext` would therefore resolve `ctx.sessions` to the host's shape.
20
+ * The harness avoids this with separate client and host programs; a two-file
21
+ * package states the three members it uses instead, which also leaves this
22
+ * half with no harness type dependency at all.
23
+ * @module @tivility/dsh-session-share/client
24
+ */
25
+ /** The session-list snapshot this plugin reads. */
26
+ interface SessionListSnapshot {
27
+ /** Every listed session, keyed by id. */
28
+ readonly byId: Readonly<Record<string, unknown>>;
29
+ /** Whether the list has finished arriving. */
30
+ readonly phase: 'pending' | 'ready';
31
+ }
32
+ /** The part of the client sessions service this plugin drives. */
33
+ interface SessionsFace {
34
+ readonly list: {
35
+ getSnapshot(): SessionListSnapshot;
36
+ subscribe(listener: () => void): () => void;
37
+ };
38
+ /**
39
+ * Select a session as current. Fails loud on an id the list does not carry,
40
+ * which is why the phase below is consulted first.
41
+ */
42
+ open(id: string): void;
43
+ }
44
+ /** The client context members this plugin uses. */
45
+ interface ShareContext {
46
+ readonly sessions: SessionsFace;
47
+ effect(run: () => () => void): unknown;
48
+ readonly logger?: {
49
+ warn?(message: string): void;
50
+ };
51
+ }
52
+ /** Client-side services this plugin drives. */
53
+ export declare const inject: string[];
54
+ /**
55
+ * Watch the session list until the named session can be opened.
56
+ * @param ctx - the client root context.
57
+ */
58
+ export declare function apply(ctx: ShareContext): void;
59
+ export {};
60
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/client/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAIH,mDAAmD;AACnD,UAAU,mBAAmB;IAC3B,yCAAyC;IACzC,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;IAChD,8CAA8C;IAC9C,QAAQ,CAAC,KAAK,EAAE,SAAS,GAAG,OAAO,CAAA;CACpC;AAED,kEAAkE;AAClE,UAAU,YAAY;IACpB,QAAQ,CAAC,IAAI,EAAE;QACb,WAAW,IAAI,mBAAmB,CAAA;QAClC,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAA;KAC5C,CAAA;IACD;;;OAGG;IACH,IAAI,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB;AAED,mDAAmD;AACnD,UAAU,YAAY;IACpB,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAA;IAC/B,MAAM,CAAC,GAAG,EAAE,MAAM,MAAM,IAAI,GAAG,OAAO,CAAA;IACtC,QAAQ,CAAC,MAAM,CAAC,EAAE;QAAE,IAAI,CAAC,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAA;CACnD;AAED,+CAA+C;AAC/C,eAAO,MAAM,MAAM,UAAe,CAAA;AAiBlC;;;GAGG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI,CAgC7C"}
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Link straight to one session.
3
+ *
4
+ * The harness's GUI opens wherever it was left. There is no URL that means
5
+ * "this conversation" — the whole client carries no routing at all, and the
6
+ * selected session is restored from persisted state. So a session cannot be
7
+ * pointed at: not in a message, not in a ticket, not by the agent describing
8
+ * its own work.
9
+ *
10
+ * This plugin adds the one missing step. The browser half reads a `?session=`
11
+ * parameter and selects that session; this half serves a short redirect route
12
+ * and teaches the model the link format so it can hand out links to the
13
+ * conversations it starts.
14
+ *
15
+ * **It shares nothing.** Every session stays visible and the GUI is otherwise
16
+ * untouched — the link decides where a visitor lands, not what they can see.
17
+ * Restricting that is what `@tivility/dsh-readonly-auth` is for, and this
18
+ * plugin deliberately does not pretend to do it: hiding the sidebar would look
19
+ * like isolation while `/api` still answered every question about every other
20
+ * session.
21
+ * @module @tivility/dsh-session-share
22
+ */
23
+ import type { Context } from '@deepseek-ai/cordis';
24
+ import z from '@deepseek-ai/schemastery';
25
+ export { SESSION_PARAM, shareQuery } from './param.js';
26
+ /** Stable Cordis plugin name. */
27
+ export declare const name = "session-share";
28
+ /** The HTTP carrier this plugin mounts its redirect on. */
29
+ export declare const inject: string[];
30
+ /** Plugin configuration. */
31
+ export interface Config {
32
+ /**
33
+ * Prefix for the short link form, `<route>/<session-id>`. Empty mounts no
34
+ * route, leaving only the query form.
35
+ */
36
+ route?: string;
37
+ /** Teach the model the link format so it can hand out links to its own sessions. */
38
+ prompt?: boolean;
39
+ /** Non-loopback authorities this deployment serves, as `host` or `host:port`. */
40
+ trustedHosts?: string[];
41
+ }
42
+ export declare const Config: z<Config>;
43
+ /**
44
+ * Mount the redirect route and the prompt section.
45
+ * @param ctx - plugin context carrying the injected `webServer` service.
46
+ * @param config - plugin configuration.
47
+ */
48
+ export declare function apply(ctx: Context, config?: Config): void;
49
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,CAAC,MAAM,0BAA0B,CAAA;AAQxC,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAEtD,iCAAiC;AACjC,eAAO,MAAM,IAAI,kBAAkB,CAAA;AAEnC,2DAA2D;AAC3D,eAAO,MAAM,MAAM,UAAgB,CAAA;AAEnC,4BAA4B;AAC5B,MAAM,WAAW,MAAM;IACrB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,oFAAoF;IACpF,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,iFAAiF;IACjF,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;CACxB;AAED,eAAO,MAAM,MAAM,EAAE,CAAC,CAAC,MAAM,CAI3B,CAAA;AA0EF;;;;GAIG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,GAAE,MAAW,GAAG,IAAI,CA2B7D"}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The one fact both halves of this plugin have to agree on.
3
+ *
4
+ * The query parameter is a constant rather than configuration because the two
5
+ * halves cannot share a configuration value: the browser bundle is fetched
6
+ * outside the loader's config tree and receives only what its own `apply`
7
+ * is handed, while the link the host writes into the system prompt is built
8
+ * on the other side of the wire. A constant is the honest form of a value
9
+ * that must match in two places.
10
+ * @module @tivility/dsh-session-share/param
11
+ */
12
+ /** Query parameter naming the session to open on load. */
13
+ export declare const SESSION_PARAM = "session";
14
+ /**
15
+ * Build the query a share link carries.
16
+ * @param sessionId - the session to open.
17
+ * @returns the query string, question mark included.
18
+ */
19
+ export declare function shareQuery(sessionId: string): string;
20
+ //# sourceMappingURL=param.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"param.d.ts","sourceRoot":"","sources":["../../src/param.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,0DAA0D;AAC1D,eAAO,MAAM,aAAa,YAAY,CAAA;AAEtC;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAEpD"}
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@tivility/dsh-session-share",
3
+ "version": "0.1.0",
4
+ "description": "Link straight to one DeepSeek Harness session: the GUI opens on it instead of wherever it was left.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./lib/index.js",
8
+ "types": "./lib/types/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./lib/types/index.d.ts",
12
+ "default": "./lib/index.js"
13
+ },
14
+ "./client": {
15
+ "types": "./lib/types/client/index.d.ts",
16
+ "default": "./lib/client.js"
17
+ },
18
+ "./package.json": "./package.json"
19
+ },
20
+ "files": [
21
+ "README.md",
22
+ "lib/**",
23
+ "src/**"
24
+ ],
25
+ "keywords": [
26
+ "dsh-plugin",
27
+ "deepseek-harness",
28
+ "session",
29
+ "deep-link"
30
+ ],
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/Tivility/dsh-plugins.git",
34
+ "directory": "packages/session-share"
35
+ },
36
+ "dsh": {
37
+ "client": {
38
+ "platform": "web",
39
+ "inject": [
40
+ "@deepseek-ai/dsh-client-runtime"
41
+ ]
42
+ }
43
+ },
44
+ "dependencies": {
45
+ "@tivility/dsh-web-kit": "^0.1.0"
46
+ },
47
+ "peerDependencies": {
48
+ "@deepseek-ai/cordis": "*",
49
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.1-rc.2",
50
+ "@deepseek-ai/schemastery": "*",
51
+ "@deepseek-ai/dsh-agent": ">=0.1.1-rc.2"
52
+ },
53
+ "peerDependenciesMeta": {
54
+ "@deepseek-ai/dsh-system-prompt": {
55
+ "optional": true
56
+ },
57
+ "@deepseek-ai/dsh-agent": {
58
+ "optional": true
59
+ }
60
+ },
61
+ "devDependencies": {
62
+ "@deepseek-ai/cordis": "*",
63
+ "@deepseek-ai/dsh-agent": "0.1.1-rc.2",
64
+ "@deepseek-ai/dsh-client-runtime": "0.1.1-rc.2",
65
+ "@deepseek-ai/dsh-host-webserver": "0.1.1-rc.2",
66
+ "@deepseek-ai/dsh-system-prompt": "0.1.1-rc.2",
67
+ "@deepseek-ai/schemastery": "*",
68
+ "tsdown": "^0.20.0"
69
+ },
70
+ "publishConfig": {
71
+ "access": "public"
72
+ },
73
+ "scripts": {
74
+ "bundle": "tsdown"
75
+ }
76
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The browser half: open the session a link names.
3
+ *
4
+ * The harness's GUI has no routing — the whole client is searched in vain for
5
+ * `pushState`, `location.hash`, or `searchParams`. The selected session lives
6
+ * in the sessions manager and is restored from persisted state on load, so a
7
+ * URL naming one has nowhere to be read. This adds that one step and nothing
8
+ * else: read the parameter, select the session, get out of the way.
9
+ *
10
+ * It deliberately hides nothing. The sidebar, the other sessions, and the
11
+ * composer are all exactly as they would be — the link decides where you land,
12
+ * not what you can see.
13
+ *
14
+ * ## Why the context is typed here
15
+ *
16
+ * The two halves of this package compile in one TypeScript program, and the
17
+ * node half pulls in the harness's HOST augmentations of `Context` — where
18
+ * `sessions` is a different service with the same name. Importing
19
+ * `ClientContext` would therefore resolve `ctx.sessions` to the host's shape.
20
+ * The harness avoids this with separate client and host programs; a two-file
21
+ * package states the three members it uses instead, which also leaves this
22
+ * half with no harness type dependency at all.
23
+ * @module @tivility/dsh-session-share/client
24
+ */
25
+
26
+ import { SESSION_PARAM } from '../param.js'
27
+
28
+ /** The session-list snapshot this plugin reads. */
29
+ interface SessionListSnapshot {
30
+ /** Every listed session, keyed by id. */
31
+ readonly byId: Readonly<Record<string, unknown>>
32
+ /** Whether the list has finished arriving. */
33
+ readonly phase: 'pending' | 'ready'
34
+ }
35
+
36
+ /** The part of the client sessions service this plugin drives. */
37
+ interface SessionsFace {
38
+ readonly list: {
39
+ getSnapshot(): SessionListSnapshot
40
+ subscribe(listener: () => void): () => void
41
+ }
42
+ /**
43
+ * Select a session as current. Fails loud on an id the list does not carry,
44
+ * which is why the phase below is consulted first.
45
+ */
46
+ open(id: string): void
47
+ }
48
+
49
+ /** The client context members this plugin uses. */
50
+ interface ShareContext {
51
+ readonly sessions: SessionsFace
52
+ effect(run: () => () => void): unknown
53
+ readonly logger?: { warn?(message: string): void }
54
+ }
55
+
56
+ /** Client-side services this plugin drives. */
57
+ export const inject = ['sessions']
58
+
59
+ /**
60
+ * Remove the parameter from the address bar once it has been acted on.
61
+ *
62
+ * The selection is persisted by the runtime, so leaving the parameter in place
63
+ * would make every later reload jump back to the linked session — fighting the
64
+ * user each time they navigate away and refresh. Consuming it once makes the
65
+ * link mean "start here", which is what a link means.
66
+ */
67
+ function stripParam(): void {
68
+ const url = new URL(window.location.href)
69
+ if (!url.searchParams.has(SESSION_PARAM)) return
70
+ url.searchParams.delete(SESSION_PARAM)
71
+ window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`)
72
+ }
73
+
74
+ /**
75
+ * Watch the session list until the named session can be opened.
76
+ * @param ctx - the client root context.
77
+ */
78
+ export function apply(ctx: ShareContext): void {
79
+ const requested = new URL(window.location.href).searchParams.get(SESSION_PARAM)
80
+ // No parameter is the overwhelmingly common case — every ordinary visit — so
81
+ // this plugin costs nothing at all when it has nothing to do.
82
+ if (requested === null || requested === '') return
83
+
84
+ ctx.effect(() => {
85
+ let done = false
86
+ const attempt = (): void => {
87
+ if (done) return
88
+ const state = ctx.sessions.list.getSnapshot()
89
+ if (state.byId[requested] !== undefined) {
90
+ done = true
91
+ ctx.sessions.open(requested)
92
+ stripParam()
93
+ return
94
+ }
95
+ // The list arrives asynchronously, so an id that is absent right now may
96
+ // simply not have loaded. The phase is what separates "not yet" from "no
97
+ // such session" — a timeout would guess at it.
98
+ if (state.phase === 'ready') {
99
+ done = true
100
+ stripParam()
101
+ ctx.logger?.warn?.(`session-share: no session ${requested} in this harness`)
102
+ }
103
+ }
104
+ const unsubscribe = ctx.sessions.list.subscribe(attempt)
105
+ // The list may already be ready before this plugin activates, in which case
106
+ // no change is ever published and the subscription alone would wait forever.
107
+ attempt()
108
+ return unsubscribe
109
+ })
110
+ }
package/src/index.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Link straight to one session.
3
+ *
4
+ * The harness's GUI opens wherever it was left. There is no URL that means
5
+ * "this conversation" — the whole client carries no routing at all, and the
6
+ * selected session is restored from persisted state. So a session cannot be
7
+ * pointed at: not in a message, not in a ticket, not by the agent describing
8
+ * its own work.
9
+ *
10
+ * This plugin adds the one missing step. The browser half reads a `?session=`
11
+ * parameter and selects that session; this half serves a short redirect route
12
+ * and teaches the model the link format so it can hand out links to the
13
+ * conversations it starts.
14
+ *
15
+ * **It shares nothing.** Every session stays visible and the GUI is otherwise
16
+ * untouched — the link decides where a visitor lands, not what they can see.
17
+ * Restricting that is what `@tivility/dsh-readonly-auth` is for, and this
18
+ * plugin deliberately does not pretend to do it: hiding the sidebar would look
19
+ * like isolation while `/api` still answered every question about every other
20
+ * session.
21
+ * @module @tivility/dsh-session-share
22
+ */
23
+
24
+ import type { IncomingMessage, ServerResponse } from 'node:http'
25
+ import type { Context } from '@deepseek-ai/cordis'
26
+ import z from '@deepseek-ai/schemastery'
27
+ import type {} from '@deepseek-ai/dsh-host-webserver'
28
+ import type {} from '@deepseek-ai/dsh-system-prompt'
29
+ // Type-only: contributes the `agent` field on the prompt assembly context.
30
+ import type {} from '@deepseek-ai/dsh-agent'
31
+ import { absoluteUrl, isTrustedRequest, sendStatus } from '@tivility/dsh-web-kit'
32
+ import { SESSION_PARAM, shareQuery } from './param.js'
33
+
34
+ export { SESSION_PARAM, shareQuery } from './param.js'
35
+
36
+ /** Stable Cordis plugin name. */
37
+ export const name = 'session-share'
38
+
39
+ /** The HTTP carrier this plugin mounts its redirect on. */
40
+ export const inject = ['webServer']
41
+
42
+ /** Plugin configuration. */
43
+ export interface Config {
44
+ /**
45
+ * Prefix for the short link form, `<route>/<session-id>`. Empty mounts no
46
+ * route, leaving only the query form.
47
+ */
48
+ route?: string
49
+ /** Teach the model the link format so it can hand out links to its own sessions. */
50
+ prompt?: boolean
51
+ /** Non-loopback authorities this deployment serves, as `host` or `host:port`. */
52
+ trustedHosts?: string[]
53
+ }
54
+
55
+ export const Config: z<Config> = z.object({
56
+ route: z.string().default('/s'),
57
+ prompt: z.boolean().default(true),
58
+ trustedHosts: z.array(String).default([]),
59
+ })
60
+
61
+ /** A session id as it may appear in a URL: the harness mints uuids. */
62
+ const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/
63
+
64
+ /**
65
+ * Build the redirect handler for the short link form.
66
+ *
67
+ * A redirect rather than a page, because the GUI is a single-page app served
68
+ * only at the root: the harness's static fallback serves `index.html` for `/`
69
+ * and `/index.html` and 404s everything else, with no SPA rewrite. `/s/<id>`
70
+ * would therefore be a 404, so the short form bounces to the query form the
71
+ * browser half reads.
72
+ * @param route - the configured prefix.
73
+ * @param trustedHosts - authorities the fence accepts.
74
+ * @returns the route handler.
75
+ */
76
+ function createHandler(
77
+ route: string,
78
+ trustedHosts: readonly string[],
79
+ ): (req: IncomingMessage, res: ServerResponse) => void {
80
+ return (req, res) => {
81
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
82
+ sendStatus(res, 405, 'method not allowed', { allow: 'GET, HEAD' })
83
+ return
84
+ }
85
+ if (!isTrustedRequest(req, trustedHosts)) {
86
+ sendStatus(res, 403, 'forbidden')
87
+ return
88
+ }
89
+ /* v8 ignore next -- node:http always sets url on server requests */
90
+ const pathname = new URL(req.url ?? '/', 'http://x').pathname
91
+ let id: string
92
+ try {
93
+ id = decodeURIComponent(pathname.slice(route.length).replace(/^\//, ''))
94
+ } catch {
95
+ sendStatus(res, 400, 'malformed session id')
96
+ return
97
+ }
98
+ // Validated before it becomes a Location: the value lands in a header, and
99
+ // an unconstrained one is where header injection lives.
100
+ if (!SESSION_ID.test(id)) {
101
+ sendStatus(res, 400, 'malformed session id')
102
+ return
103
+ }
104
+ res.writeHead(302, { location: `/${shareQuery(id)}` })
105
+ res.end()
106
+ }
107
+ }
108
+
109
+ /**
110
+ * The system prompt section teaching the model this link format.
111
+ * @param ctx - a context carrying the `webServer` service.
112
+ * @param route - the configured short-link prefix.
113
+ * @param sessionId - the session the calling agent belongs to.
114
+ * @returns the prompt text.
115
+ */
116
+ function promptSection(ctx: Context, route: string, sessionId: string): string {
117
+ const base = absoluteUrl(ctx, '/')
118
+ const link = `${base}${shareQuery(sessionId)}`
119
+ const shortForm = route === ''
120
+ ? ''
121
+ : ` The shorter ${absoluteUrl(ctx, route)}/${sessionId} redirects to the same place.`
122
+ return [
123
+ `This conversation has a direct link: ${link}`,
124
+ `Opening it in a browser selects this session instead of whatever was last open.${shortForm}`,
125
+ 'Give it to the user when they need to come back to this conversation, point someone at it,',
126
+ 'or file it somewhere. Any session id works in the same shape — a subagent you started,',
127
+ 'a session you forked — so a link can point at that one instead of this one.',
128
+ 'The link decides which session opens, not who may read it: everyone who can reach the',
129
+ 'harness sees the same thing they would see without it.',
130
+ ].join(' ')
131
+ }
132
+
133
+ /**
134
+ * Mount the redirect route and the prompt section.
135
+ * @param ctx - plugin context carrying the injected `webServer` service.
136
+ * @param config - plugin configuration.
137
+ */
138
+ export function apply(ctx: Context, config: Config = {}): void {
139
+ const route = config.route ?? '/s'
140
+ const trustedHosts = config.trustedHosts ?? []
141
+ if (route !== '') {
142
+ if (!route.startsWith('/') || route.endsWith('/')) {
143
+ throw new Error(`session-share: route ${JSON.stringify(route)} must be an absolute path without a trailing slash`)
144
+ }
145
+ ctx.effect(() => ctx.webServer.register({
146
+ kind: 'prefix',
147
+ path: route,
148
+ handler: createHandler(route, trustedHosts),
149
+ }), `session-share: ${route} route`)
150
+ }
151
+
152
+ if (config.prompt === false) return
153
+ ctx.inject(['systemPrompt'], (scope) => {
154
+ scope.systemPrompt.section({
155
+ name: 'session-share',
156
+ order: 91,
157
+ text: (context) => {
158
+ // Resolved per assembly: the port is known only after the server binds,
159
+ // and the session id is whichever agent this prompt is being built for.
160
+ const sessionId = context.agent?.session.id
161
+ return sessionId === undefined ? '' : promptSection(scope, route, String(sessionId))
162
+ },
163
+ })
164
+ })
165
+ }
package/src/param.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The one fact both halves of this plugin have to agree on.
3
+ *
4
+ * The query parameter is a constant rather than configuration because the two
5
+ * halves cannot share a configuration value: the browser bundle is fetched
6
+ * outside the loader's config tree and receives only what its own `apply`
7
+ * is handed, while the link the host writes into the system prompt is built
8
+ * on the other side of the wire. A constant is the honest form of a value
9
+ * that must match in two places.
10
+ * @module @tivility/dsh-session-share/param
11
+ */
12
+
13
+ /** Query parameter naming the session to open on load. */
14
+ export const SESSION_PARAM = 'session'
15
+
16
+ /**
17
+ * Build the query a share link carries.
18
+ * @param sessionId - the session to open.
19
+ * @returns the query string, question mark included.
20
+ */
21
+ export function shareQuery(sessionId: string): string {
22
+ return `?${SESSION_PARAM}=${encodeURIComponent(sessionId)}`
23
+ }