@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 +103 -0
- package/lib/client/index.js +81 -0
- package/lib/client/index.js.map +1 -0
- package/lib/client.js +96 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +136 -0
- package/lib/index.js.map +1 -0
- package/lib/param.js +22 -0
- package/lib/param.js.map +1 -0
- package/lib/types/client/index.d.ts +60 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/index.d.ts +49 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/param.d.ts +20 -0
- package/lib/types/param.d.ts.map +1 -0
- package/package.json +76 -0
- package/src/client/index.ts +110 -0
- package/src/index.ts +165 -0
- package/src/param.ts +23 -0
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
|
package/lib/index.js.map
ADDED
|
@@ -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
|
package/lib/param.js.map
ADDED
|
@@ -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
|
+
}
|