@danypops/papyrus 0.16.0 → 0.17.1

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.
@@ -8,6 +8,7 @@ import type { TaskHistoryPage } from "../../src/domain/task-event.ts";
8
8
  import type { TaskCompletion, TaskGraph } from "../../src/task-service.ts";
9
9
  import type { SkillWorkflowRunResult } from "../../src/skill-execution.ts";
10
10
  import { emitTaskFocusEvent } from "./task-focus-events.ts";
11
+ import { sessionSecretField } from "./session-identity.ts";
11
12
  import { NOTE_DISPOSITIONS } from "../../src/note-service.ts";
12
13
  import { callService } from "./service-client.ts";
13
14
  import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
@@ -77,7 +78,12 @@ export function registerDomainTools(pi: ExtensionAPI): void {
77
78
  const action = params.action;
78
79
  // Defaults to this Pi session's own id so Focus reads/writes are isolated per agent
79
80
  // without depending on the model to know or supply its own session identity.
80
- const request = { ...params, project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "pi-tool", session_id: params.session_id ?? ctx.sessionManager.getSessionId() };
81
+ // session_secret is looked up by the resolved session_id itself (not blindly the
82
+ // current session's), so a model that explicitly overrides session_id to a DIFFERENT
83
+ // session never gets this session's secret smuggled in on its behalf -- the cache only
84
+ // ever holds this extension's own registered session anyway (see session-identity.ts).
85
+ const resolvedSessionId = params.session_id ?? ctx.sessionManager.getSessionId();
86
+ const request = { ...params, project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "pi-tool", session_id: resolvedSessionId, ...sessionSecretField(resolvedSessionId as string) };
81
87
  if (action === "create") {
82
88
  const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
83
89
  return text(`Created task ${artifactLine(artifact)}`, createArtifactDetails("tasks.create", artifact));
@@ -31,6 +31,7 @@ import { buildContextBreakdown, buildMessageHistoryTree, buildTaskItemTree, comp
31
31
  import { buildBasePromptItems } from "./base-prompt-breakdown.ts";
32
32
  import { showContextView } from "./context-view.ts";
33
33
  import { emitTaskFocusEvent, setTaskFocusEventBus } from "./task-focus-events.ts";
34
+ import { cacheSessionSecret, forgetSessionSecret, sessionSecretField } from "./session-identity.ts";
34
35
  import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
35
36
  import {
36
37
  createArtifactDetails,
@@ -197,6 +198,7 @@ export default async function (pi: ExtensionAPI) {
197
198
  source: "task-continuation",
198
199
  reason: automaticPauseReason(decision.reason),
199
200
  session_id: sessionId,
201
+ ...sessionSecretField(sessionId),
200
202
  });
201
203
  emitTaskFocusEvent({ taskId: paused.artifact.id, sessionId, status: "paused" });
202
204
  if (ctx.hasUI) ctx.ui.notify(`Papyrus task driving paused: ${decision.reason}. Human input resumes it automatically.`, "warning");
@@ -499,6 +501,19 @@ export default async function (pi: ExtensionAPI) {
499
501
  // ── Task widget (TodoOverlay pattern: factory form, requestRender) ──
500
502
 
501
503
  pi.on("session_start", async (_event, ctx) => {
504
+ // Registers this session's identity with the daemon as early as possible -- before any
505
+ // Focus-mutating call could plausibly happen -- shrinking (not eliminating; see
506
+ // domain/session-identity.ts) the first-touch race window. Best-effort: the daemon may be
507
+ // unavailable during startup, and every other Focus-mutating call already tolerates an
508
+ // unregistered/never-armored session_id (opt-in armor), so a missed registration here is
509
+ // not worth surfacing to the user.
510
+ try {
511
+ const sessionId = ctx.sessionManager.getSessionId();
512
+ const { secret } = await callService<Record<string, unknown>, { sessionId: string; secret: string }>("session.register", { session_id: sessionId });
513
+ cacheSessionSecret(sessionId, secret);
514
+ } catch {
515
+ // intentionally silent -- see comment above
516
+ }
502
517
  if (!ctx.hasUI) return;
503
518
  overlay ??= new TaskOverlay();
504
519
  overlay.setUI(ctx.ui);
@@ -510,7 +525,17 @@ export default async function (pi: ExtensionAPI) {
510
525
  pi.on("session_before_compact", () => { taskContinuation.onCompaction(); });
511
526
  pi.on("session_compact", async () => { await overlay?.refresh(); });
512
527
  pi.on("session_tree", async () => { await overlay?.refresh(); });
513
- pi.on("session_shutdown", async () => { overlay?.dispose(); overlay = undefined; });
528
+ pi.on("session_shutdown", async (_event, ctx) => {
529
+ overlay?.dispose();
530
+ overlay = undefined;
531
+ try {
532
+ const sessionId = ctx.sessionManager.getSessionId();
533
+ await callService("session.release", { session_id: sessionId, ...sessionSecretField(sessionId) });
534
+ forgetSessionSecret(sessionId);
535
+ } catch {
536
+ // intentionally silent -- see session_start's comment above
537
+ }
538
+ });
514
539
 
515
540
  // Update widget after any papyrus tool call
516
541
  pi.on("tool_execution_end", async (event) => {
@@ -530,7 +555,7 @@ export default async function (pi: ExtensionAPI) {
530
555
  const sessionId = ctx.sessionManager.getSessionId();
531
556
  const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string; pauseReason?: string } | null>("tasks.focused", { session_id: sessionId });
532
557
  if (focus && shouldResumeFocusOnHumanInput(focus.status, focus.pauseReason)) {
533
- await callService("tasks.unpause", { actor: "system", source: "task-continuation", reason: "human input resumed automatic task continuation", session_id: sessionId });
558
+ await callService("tasks.unpause", { actor: "system", source: "task-continuation", reason: "human input resumed automatic task continuation", session_id: sessionId, ...sessionSecretField(sessionId) });
534
559
  emitTaskFocusEvent({ taskId: focus.artifact.id, sessionId, status: "unpaused" });
535
560
  }
536
561
  } catch {
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Client-side cache for the extension's own session_secret, registered with the daemon at
3
+ * session_start and released at session_shutdown (see index.ts). Keyed by sessionId (not a
4
+ * single "current" variable) defensively -- multiple call sites reference an explicit
5
+ * sessionId already, and a Map costs nothing extra for real correctness. See
6
+ * src/domain/session-identity.ts (daemon side) for the full design rationale.
7
+ */
8
+ const secretsBySessionId = new Map<string, string>();
9
+
10
+ export function cacheSessionSecret(sessionId: string, secret: string): void {
11
+ secretsBySessionId.set(sessionId, secret);
12
+ }
13
+
14
+ export function forgetSessionSecret(sessionId: string): void {
15
+ secretsBySessionId.delete(sessionId);
16
+ }
17
+
18
+ /** Spread into any Focus-mutating request body alongside session_id -- empty object when no secret is cached for this sessionId (unregistered, or registration hasn't completed yet), matching the daemon's opt-in-armor default. */
19
+ export function sessionSecretField(sessionId: string | undefined): { session_secret?: string } {
20
+ const secret = sessionId ? secretsBySessionId.get(sessionId) : undefined;
21
+ return secret ? { session_secret: secret } : {};
22
+ }
@@ -8,6 +8,7 @@ import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
8
8
  import { Container, Input, Spacer, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
9
  import { callService } from "./service-client.ts";
10
10
  import { emitTaskFocusEvent } from "./task-focus-events.ts";
11
+ import { sessionSecretField } from "./session-identity.ts";
11
12
  import { showTaskDetails } from "./task-detail-view.ts";
12
13
  import { showTaskGraph } from "./task-graph.ts";
13
14
 
@@ -175,7 +176,7 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
175
176
  }
176
177
  } else if (choice === "Make active") {
177
178
  try {
178
- const focused = await callService<Record<string, unknown>, Artifact>("tasks.focus", { id: action.row.id, actor: "user", source: "tasks-tui", session_id: sessionId });
179
+ const focused = await callService<Record<string, unknown>, Artifact>("tasks.focus", { id: action.row.id, actor: "user", source: "tasks-tui", session_id: sessionId, ...sessionSecretField(sessionId) });
179
180
  emitTaskFocusEvent({ taskId: focused.id, sessionId, status: "focused" });
180
181
  ctx.ui.notify(`Active: ${action.row.title}`, "info");
181
182
  } catch (error) {
@@ -184,11 +185,11 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
184
185
  } else if (choice === "Pause focus" || choice === "Resume focus" || choice === "Clear focus") {
185
186
  try {
186
187
  if (choice === "Clear focus") {
187
- await callService("tasks.clear_focus", { actor: "user", source: "tasks-tui", session_id: sessionId });
188
+ await callService("tasks.clear_focus", { actor: "user", source: "tasks-tui", session_id: sessionId, ...sessionSecretField(sessionId) });
188
189
  emitTaskFocusEvent({ taskId: null, sessionId, status: "cleared" });
189
190
  } else {
190
191
  const operation = choice === "Pause focus" ? "tasks.pause" : "tasks.unpause";
191
- const result = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>(operation, { actor: "user", source: "tasks-tui", session_id: sessionId });
192
+ const result = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>(operation, { actor: "user", source: "tasks-tui", session_id: sessionId, ...sessionSecretField(sessionId) });
192
193
  emitTaskFocusEvent({ taskId: result.artifact.id, sessionId, status: choice === "Pause focus" ? "paused" : "unpaused" });
193
194
  }
194
195
  ctx.ui.notify(choice === "Clear focus" ? "Task focus cleared" : choice, "info");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -41,6 +41,7 @@
41
41
  },
42
42
  "files": ["src", "extension", "README.md"],
43
43
  "dependencies": {
44
- "beautiful-mermaid": "1.1.3"
44
+ "beautiful-mermaid": "1.1.3",
45
+ "@danypops/daemon-kit": "^0.2.0"
45
46
  }
46
47
  }
@@ -0,0 +1,46 @@
1
+ import { SESSION_IDENTITY_MAX_ROWS } from "../constants.ts";
2
+ import type { Db } from "../db.ts";
3
+ import { inTransaction } from "../db.ts";
4
+ import type { SessionIdentityRecord, SessionIdentityStore } from "../ports/session-identity-store.ts";
5
+
6
+ export class SQLiteSessionIdentityStore implements SessionIdentityStore {
7
+ constructor(private readonly db: Db) {}
8
+
9
+ find(sessionId: string): SessionIdentityRecord | undefined {
10
+ const row = this.db.prepare("SELECT session_id, secret_hash, registered_at, last_seen_at FROM session_identities WHERE session_id = ?").get(sessionId) as
11
+ | { session_id: string; secret_hash: string; registered_at: string; last_seen_at: string }
12
+ | null;
13
+ return row ? { sessionId: row.session_id, secretHash: row.secret_hash, registeredAt: row.registered_at, lastSeenAt: row.last_seen_at } : undefined;
14
+ }
15
+
16
+ upsert(record: SessionIdentityRecord): void {
17
+ inTransaction(this.db, () => {
18
+ this.evictOldestBeyondCap(record.sessionId);
19
+ this.db.prepare(`
20
+ INSERT INTO session_identities (session_id, secret_hash, registered_at, last_seen_at)
21
+ VALUES (?, ?, ?, ?)
22
+ ON CONFLICT(session_id) DO UPDATE SET secret_hash = excluded.secret_hash, registered_at = excluded.registered_at, last_seen_at = excluded.last_seen_at
23
+ `).run(record.sessionId, record.secretHash, record.registeredAt, record.lastSeenAt);
24
+ });
25
+ }
26
+
27
+ remove(sessionId: string): void {
28
+ this.db.prepare("DELETE FROM session_identities WHERE session_id = ?").run(sessionId);
29
+ }
30
+
31
+ touch(sessionId: string, lastSeenAt: string): void {
32
+ this.db.prepare("UPDATE session_identities SET last_seen_at = ? WHERE session_id = ?").run(lastSeenAt, sessionId);
33
+ }
34
+
35
+ count(): number {
36
+ return (this.db.prepare("SELECT COUNT(*) AS count FROM session_identities").get() as { count: number }).count;
37
+ }
38
+
39
+ /** Bounds distinct registered session identities; evicts the least-recently-seen beyond the cap. Mirrors SQLiteTaskFocusStore.evictOldestBeyondCap exactly. */
40
+ private evictOldestBeyondCap(sessionId: string): void {
41
+ const exists = this.db.prepare("SELECT 1 FROM session_identities WHERE session_id = ?").get(sessionId);
42
+ if (exists) return;
43
+ if (this.count() < SESSION_IDENTITY_MAX_ROWS) return;
44
+ this.db.exec("DELETE FROM session_identities WHERE session_id = (SELECT session_id FROM session_identities ORDER BY last_seen_at ASC LIMIT 1)");
45
+ }
46
+ }
@@ -31,6 +31,10 @@ export class SQLiteTaskFocusStore implements TaskFocusStore {
31
31
  });
32
32
  }
33
33
 
34
+ reapStale(olderThanIso: string): number {
35
+ return this.db.prepare("DELETE FROM task_focus WHERE updated_at < ?").run(olderThanIso).changes;
36
+ }
37
+
34
38
  private transition(taskId: string, expected: TaskFocusStatus, status: TaskFocusStatus, reason: string | undefined, scope: string | undefined): TaskFocusState {
35
39
  const current = this.get(scope);
36
40
  if (current?.taskId !== taskId) throw new Error(`task "${taskId}" is not focused`);
package/src/cli.ts CHANGED
@@ -112,18 +112,21 @@ const USAGE = `Usage:
112
112
  papyrus notes promote <id> <target-id> [--reason <reason>] [--json]
113
113
  papyrus notes archive <id> <completed|duplicate|declined|superseded> [--reason <reason>] [--json]
114
114
  papyrus log append --source <id> --level <debug|info|warning|error> --message <text> --operation-id <id> [--source-label <text>] [--fields-json <json>] [--session-id <id>] [--occurred-at <iso>] [--global] [--json]
115
+ papyrus session register --session-id <id> [--json]
116
+ papyrus session release --session-id <id> [--session-secret <secret>] [--json]
115
117
  papyrus log query --source <id> [--since <iso>] [--level <debug|info|warning|error>] [--limit <count>] [--json]
116
118
  papyrus tasks plan [--session-id <id>] [--json]
117
119
  papyrus tasks graph [--session-id <id>] [--json]
118
120
  papyrus tasks active [--session-id <id>] [--json]
119
121
  papyrus tasks focused [--session-id <id>] [--json]
120
- papyrus tasks pause [--session-id <id>] [--json]
121
- papyrus tasks unpause [--session-id <id>] [--json]
122
- papyrus tasks clear-focus [--session-id <id>] [--json]
122
+ papyrus tasks pause [--session-id <id>] [--session-secret <secret>] [--json]
123
+ papyrus tasks unpause [--session-id <id>] [--session-secret <secret>] [--json]
124
+ papyrus tasks clear-focus [--session-id <id>] [--session-secret <secret>] [--json]
125
+ papyrus tasks reap-stale-focus [--json]
123
126
  papyrus tasks history <id> [--json]
124
127
  papyrus tasks scope [project|all|graph <root-id>] [--json]
125
128
  papyrus tasks assign-project <id> [project-root] [--json]
126
- papyrus tasks focus <id> [--session-id <id>] [--json]
129
+ papyrus tasks focus <id> [--session-id <id>] [--session-secret <secret>] [--json]
127
130
  papyrus tasks update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--status todo --reason <reason>] [--json]
128
131
  papyrus tasks complete <id> [--session-id <id>] [--json]
129
132
  papyrus tasks start <id> [--session-id <id>] [--json]
@@ -142,7 +145,8 @@ const USAGE = `Usage:
142
145
  papyrus tasks set-checklist <id> --checklist-json <json> [--json]
143
146
  papyrus tasks context [--scope <project|graph|all>] [--root-task-id <id>] [--session-id <id>] [--json]
144
147
 
145
- A "--session-id" scopes Task Focus to one agent session; omit it to use the shared "global" Focus (today's behavior).`;
148
+ A "--session-id" scopes Task Focus to one agent session; omit it to use the shared "global" Focus (today's behavior).
149
+ Once a session id is registered ("papyrus session register"), mutating its Focus (focus/pause/unpause/clear-focus) requires the matching "--session-secret"; an unregistered session id is unaffected.`;
146
150
 
147
151
  function usage(): never {
148
152
  console.error(USAGE);
@@ -909,6 +913,33 @@ export async function runLogCli(args: string[], client: TaskCliClient, projectRo
909
913
  throw new Error("log action must be append or query");
910
914
  }
911
915
 
916
+ export async function runSessionIdentityCli(args: string[], client: TaskCliClient): Promise<string> {
917
+ const json = args.includes("--json");
918
+ const positional: string[] = [];
919
+ let sessionId: string | undefined;
920
+ let secret: string | undefined;
921
+ for (let index = 0; index < args.length; index++) {
922
+ const argument = args[index]!;
923
+ if (argument === "--json") continue;
924
+ if (argument === "--session-id") { sessionId = args[++index]; continue; }
925
+ if (argument === "--session-secret") { secret = args[++index]; continue; }
926
+ if (argument.startsWith("--")) throw new Error(`unknown session option ${argument}`);
927
+ positional.push(argument);
928
+ }
929
+ const [action] = positional;
930
+ if (action === "register") {
931
+ if (!sessionId) throw new Error("session register requires --session-id");
932
+ const result = await client.call<Record<string, unknown>, { sessionId: string; secret: string }>("session.register", { session_id: sessionId });
933
+ return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
934
+ }
935
+ if (action === "release") {
936
+ if (!sessionId) throw new Error("session release requires --session-id");
937
+ const result = await client.call<Record<string, unknown>, { released: boolean }>("session.release", { session_id: sessionId, ...(secret ? { session_secret: secret } : {}) });
938
+ return json ? JSON.stringify(result) : JSON.stringify(result, null, 2);
939
+ }
940
+ throw new Error("session action must be register or release");
941
+ }
942
+
912
943
  export async function runNoteCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
913
944
  const json = args.includes("--json");
914
945
  const positional: string[] = [];
@@ -975,6 +1006,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
975
1006
  const updateInput: { title?: string; body?: string; labels?: string[]; status?: "todo" } = {};
976
1007
  let reason: string | undefined;
977
1008
  let sessionId: string | undefined;
1009
+ let sessionSecret: string | undefined;
978
1010
  let title: string | undefined;
979
1011
  let body: string | undefined;
980
1012
  let status: string | undefined;
@@ -997,6 +1029,11 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
997
1029
  if (!sessionId) throw new Error("--session-id requires a value");
998
1030
  continue;
999
1031
  }
1032
+ if (argument === "--session-secret") {
1033
+ sessionSecret = args[++index];
1034
+ if (!sessionSecret) throw new Error("--session-secret requires a value");
1035
+ continue;
1036
+ }
1000
1037
  if (argument === "--title" || argument === "--body" || argument === "--labels-json" || argument === "--status" || argument === "--reason") {
1001
1038
  const value = args[++index];
1002
1039
  if (value === undefined) throw new Error(`${argument} requires a value`);
@@ -1046,6 +1083,10 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
1046
1083
  const reasonSupportedActions = new Set(["update", "depend", "undepend", "contain", "uncontain"]);
1047
1084
  if (reason !== undefined && !reasonSupportedActions.has(action ?? "")) throw new Error("--reason is only supported by tasks update, depend, undepend, contain, and uncontain");
1048
1085
  const sessionScope = sessionId ? { session_id: sessionId } : {};
1086
+ // Only meaningful alongside a registered session_id (see session.register); required by the
1087
+ // daemon only for the specific Focus-mutating operations below (focus/pause/unpause/clear_focus)
1088
+ // once that session_id has been armored (see session-identity-service.ts assertAuthorized).
1089
+ const sessionSecretField = sessionSecret ? { session_secret: sessionSecret } : {};
1049
1090
  let result: unknown;
1050
1091
  let human: string;
1051
1092
  switch (action) {
@@ -1067,18 +1108,25 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
1067
1108
  case "unpause": {
1068
1109
  if (id) throw new Error(`tasks ${action} accepts no positional arguments`);
1069
1110
  const operation = action === "pause" ? "tasks.pause" : "tasks.unpause";
1070
- const focus = await client.call<Record<string, unknown>, { artifact: CliArtifact; status: string }>(operation, { actor: "user", source: "cli", ...sessionScope });
1111
+ const focus = await client.call<Record<string, unknown>, { artifact: CliArtifact; status: string }>(operation, { actor: "user", source: "cli", ...sessionScope, ...sessionSecretField });
1071
1112
  result = focus;
1072
1113
  human = `Focused (${focus.status}): ${artifactLabel(focus.artifact)}`;
1073
1114
  break;
1074
1115
  }
1075
1116
  case "clear-focus": {
1076
1117
  if (id) throw new Error("tasks clear-focus accepts no positional arguments");
1077
- const cleared = await client.call<Record<string, unknown>, { cleared: boolean }>("tasks.clear_focus", { actor: "user", source: "cli", ...sessionScope });
1118
+ const cleared = await client.call<Record<string, unknown>, { cleared: boolean }>("tasks.clear_focus", { actor: "user", source: "cli", ...sessionScope, ...sessionSecretField });
1078
1119
  result = cleared;
1079
1120
  human = cleared.cleared ? "Task focus cleared." : "No focused task.";
1080
1121
  break;
1081
1122
  }
1123
+ case "reap-stale-focus": {
1124
+ if (id) throw new Error("tasks reap-stale-focus accepts no positional arguments");
1125
+ const reaped = await client.call<Record<string, unknown>, { removed: number }>("tasks.reap_stale_focus", {});
1126
+ result = reaped;
1127
+ human = `Reaped ${reaped.removed} stale Focus scope(s).`;
1128
+ break;
1129
+ }
1082
1130
  case "create": {
1083
1131
  if (id) throw new Error("tasks create accepts no positional arguments");
1084
1132
  if (!title) throw new Error("tasks create requires --title");
@@ -1206,7 +1254,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
1206
1254
  }
1207
1255
  case "focus": {
1208
1256
  if (!id || dependencyId) throw new Error("tasks focus requires exactly one task id");
1209
- const active = await client.call<Record<string, unknown>, CliArtifact>("tasks.focus", { id, actor: "user", source: "cli", ...sessionScope });
1257
+ const active = await client.call<Record<string, unknown>, CliArtifact>("tasks.focus", { id, actor: "user", source: "cli", ...sessionScope, ...sessionSecretField });
1210
1258
  result = active;
1211
1259
  human = `Active: ${artifactLabel(active)}`;
1212
1260
  break;
@@ -1308,6 +1356,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
1308
1356
  console.log(await runLogCli(args.slice(1), client));
1309
1357
  return;
1310
1358
  }
1359
+ if (command === "session") {
1360
+ const client = await connectPapyrusClient();
1361
+ console.log(await runSessionIdentityCli(args.slice(1), client));
1362
+ return;
1363
+ }
1311
1364
  if (command === "migrate") {
1312
1365
  const client = await connectPapyrusClient();
1313
1366
  console.log(await runMigrationCli(args.slice(1), client));
package/src/constants.ts CHANGED
@@ -7,7 +7,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
7
7
  export const DAEMON_UNIT_NAME = "papyrus.service";
8
8
  export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
9
9
  export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
10
- export const SQLITE_SCHEMA_VERSION = 12;
10
+ export const SQLITE_SCHEMA_VERSION = 13;
11
11
  export const SERVICE_MAX_BODY_BYTES = 1_048_576;
12
12
 
13
13
  export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
@@ -146,6 +146,19 @@ export const TASK_FOCUS_DEFAULT_SCOPE = "global";
146
146
  export const TASK_FOCUS_SCOPE_MAX_LENGTH = 128;
147
147
  /** Hard cap on distinct concurrent focus scopes (sessions); oldest-updated scope is evicted beyond this. */
148
148
  export const TASK_FOCUS_MAX_SCOPES = 500;
149
+ /**
150
+ * A Task Focus row not touched by any Focus-mutating operation (focus/pause/unpause) in this
151
+ * long is eligible for time-based reaping (see Tasks.reapStaleFocus), independent of the
152
+ * TASK_FOCUS_MAX_SCOPES hard cap above -- see clean-up-stale-per-session-task-focus-rows-
153
+ * on-real-session-l-9i7s. Deliberately NOT driven by Pi's session_start/session_shutdown
154
+ * hooks: a "resume" reuses the exact same session_id as a prior process incarnation, so
155
+ * neither hook reliably signals "this session is gone forever" -- only real elapsed time
156
+ * without any Focus activity does. 30 days is long enough that a genuine multi-week pause-
157
+ * and-resume workflow survives; short enough to actually bound long-run accumulation.
158
+ */
159
+ export const TASK_FOCUS_STALE_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
160
+ /** Hard cap on registered session_identities rows (see domain/session-identity.ts); oldest-seen identity is evicted beyond this, mirroring TASK_FOCUS_MAX_SCOPES. */
161
+ export const SESSION_IDENTITY_MAX_ROWS = 2_000;
149
162
  /** Persisted project and focused-graph Task view bounds. */
150
163
  export const TASK_SCOPE_MAX_TASKS = 1_000;
151
164
  /** Docs/Rules/Skills project scope listing bound, mirroring TASK_SCOPE_MAX_TASKS. */
package/src/daemon.ts CHANGED
@@ -25,12 +25,22 @@ export function serveMain(): void {
25
25
  const optimizeTimer = setInterval(() => {
26
26
  try { service.optimize(); } catch (error) { logEvent("error", "optimize_failed", { message: error instanceof Error ? error.message : String(error) }); }
27
27
  }, DB_OPTIMIZE_INTERVAL_MS);
28
+ // Daily cadence (reusing DB_OPTIMIZE_INTERVAL_MS) is plenty against a 30-day staleness
29
+ // threshold (TASK_FOCUS_STALE_AFTER_MS) -- see clean-up-stale-per-session-task-focus-rows-
30
+ // on-real-session-l-9i7s.
31
+ const reapFocusTimer = setInterval(() => {
32
+ try {
33
+ const removed = service.reapStaleFocus();
34
+ if (removed > 0) logEvent("info", "stale_focus_reaped", { removed });
35
+ } catch (error) { logEvent("error", "reap_stale_focus_failed", { message: error instanceof Error ? error.message : String(error) }); }
36
+ }, DB_OPTIMIZE_INTERVAL_MS);
28
37
  let stopping = false;
29
38
  const shutdown = () => {
30
39
  if (stopping) return;
31
40
  stopping = true;
32
41
  clearInterval(checkpointTimer);
33
42
  clearInterval(optimizeTimer);
43
+ clearInterval(reapFocusTimer);
34
44
  clearDaemonPort(stateDir);
35
45
  service.close();
36
46
  void server.stop(true).finally(() => process.exit(0));
package/src/db.ts CHANGED
@@ -20,7 +20,8 @@ const DatabaseCtor = (
20
20
  ) as new (path: string, opts?: { create?: boolean }) => Db;
21
21
 
22
22
  export interface DbStatement {
23
- run(...params: unknown[]): { lastInsertRowid: number | bigint };
23
+ /** changes: number of rows the statement affected. Both bun:sqlite and node:sqlite's real run() return this at runtime; declared here so callers (e.g. reapStale) can rely on it without an unsafe cast. */
24
+ run(...params: unknown[]): { lastInsertRowid: number | bigint; changes: number };
24
25
  get(...params: unknown[]): unknown;
25
26
  all(...params: unknown[]): unknown[];
26
27
  }
@@ -210,6 +211,12 @@ CREATE TABLE IF NOT EXISTS log_entries (
210
211
  CREATE INDEX IF NOT EXISTS log_entries_source_idx ON log_entries(source_id, occurred_at, id);
211
212
  CREATE TRIGGER IF NOT EXISTS log_entries_no_update BEFORE UPDATE ON log_entries
212
213
  BEGIN SELECT RAISE(ABORT, 'log_entries are immutable once written; retention trimming is the only supported deletion path'); END;
214
+ CREATE TABLE IF NOT EXISTS session_identities (
215
+ session_id TEXT PRIMARY KEY,
216
+ secret_hash TEXT NOT NULL,
217
+ registered_at TEXT NOT NULL,
218
+ last_seen_at TEXT NOT NULL
219
+ );
213
220
  `;
214
221
 
215
222
  const SEED_SQL = `
@@ -287,6 +294,7 @@ const CORE_LEDGER_VERSIONS: ReadonlyArray<{ version: number; name: string; check
287
294
  { version: 2, name: "docs-rules-skills-project-scope", checksum: "8b16d8f631ad628f4799ff09b1ebe8be28343e4f677d52bf2a39a8bedc19e64e" },
288
295
  { version: 3, name: "log-domain", checksum: "c87f43c22b2608619ada9a529d7899ae74b7f38cd554135c8034116fc96e1eff" },
289
296
  { version: 4, name: "remove-discourse", checksum: "b923f41c44460f0aaeb2f4e60e28f8b8e1425d03f527955bd991434b46de4c82" },
297
+ { version: 5, name: "session-identity", checksum: "1c6a165bbe37f82a100fd34762db70c3f8ab15ff20c3a53c2e60448edc815a5e" },
290
298
  ];
291
299
 
292
300
  export function migrationLedger(db: Db): ModuleMigrationRow[] {
@@ -637,6 +645,22 @@ export function migrateDb(db: Db): MigrationResult {
637
645
  `);
638
646
  applied.push("remove-discourse");
639
647
  }
648
+ if (schemaVersion(db) === 12) {
649
+ // See domain/session-identity.ts and verify-caller-identity-behind-papyrus-mutation-
650
+ // attribution-koxt: first-touch capability binding for session_id, the one place it is
651
+ // behavior-affecting today (Task Focus). Purely additive -- a session_id that never
652
+ // registers here behaves exactly as before.
653
+ db.exec(`
654
+ CREATE TABLE IF NOT EXISTS session_identities (
655
+ session_id TEXT PRIMARY KEY,
656
+ secret_hash TEXT NOT NULL,
657
+ registered_at TEXT NOT NULL,
658
+ last_seen_at TEXT NOT NULL
659
+ );
660
+ PRAGMA user_version = 13;
661
+ `);
662
+ applied.push("session-identity");
663
+ }
640
664
  if (schemaVersion(db) !== SQLITE_SCHEMA_VERSION) throw new Error(`no explicit migration path from database schema ${from}`);
641
665
  });
642
666
  if (schemaVersion(db) === SQLITE_SCHEMA_VERSION) ensureCoreLedger(db, true);
@@ -0,0 +1,50 @@
1
+ /**
2
+ * domain/session-identity.ts — closes the gap flagged by
3
+ * verify-caller-identity-behind-papyrus-mutation-attribution-koxt: session_id was, until now,
4
+ * pure caller-supplied free text at the RPC boundary, yet scope-task-focus-and-its-
5
+ * tuicontext-injection-to-the-request-8d5n made it load-bearing for *behavior* (Task Focus
6
+ * is keyed by session_id) -- a forged session_id can pause/unpause/clear/redirect a live
7
+ * session's Focus, not merely mislabel history.
8
+ *
9
+ * The real constraint this design works within: Papyrus's daemon authenticates every client
10
+ * with ONE shared bearer token per machine (src/daemon-state.ts) -- every authenticated
11
+ * caller looks identical to the daemon. Checked directly against Bun's public server API:
12
+ * no SO_PEERCRED-equivalent exists for either a TCP or a Unix-socket listener, so real
13
+ * kernel-verified per-process identity is not cheaply achievable; building it would mean FFI
14
+ * against libc, disproportionate to this task. Pi's session ids are uuidv7 (time-ordered, not
15
+ * cryptographically opaque -- confirmed by reading @earendil-works/pi-coding-agent's
16
+ * session-manager.ts source), so they were never meant to double as secrets either.
17
+ *
18
+ * The actual cryptographic primitive (secret generation, hashing, constant-time verify,
19
+ * first-touch registration semantics) is NOT reimplemented here -- it lives in
20
+ * @danypops/daemon-kit's session-identity module. That gap (a shared bearer token cannot
21
+ * distinguish callers; a session id needs a real credential once it becomes behavior-
22
+ * affecting) is generic to every daemon-kit-shaped daemon, not Papyrus-specific -- Papyrus's
23
+ * own daemon.ts/service.ts predates daemon-kit and has not migrated onto it, but this one
24
+ * capability is adopted narrowly regardless (daemon-kit's exports map is designed for
25
+ * exactly this: "a consumer only pulls in what it uses"). This file only wires that generic
26
+ * primitive to Papyrus's own SQLite storage (see adapters/sqlite-session-identity-store.ts)
27
+ * and to Task Focus specifically, the one place session_id is behavior-affecting today.
28
+ *
29
+ * Deliberately "opt-in armor", not a breaking migration: a session_id that was never
30
+ * registered behaves exactly as before (open), so every already-open session and every
31
+ * caller that never calls session.register (bare CLI use of the "global" scope, older
32
+ * Papyrus builds, test fixtures) is unaffected. Every real Pi session becomes armored
33
+ * automatically the moment its extension fires session_start.
34
+ *
35
+ * Explicitly NOT solved by this: a race at first contact (whoever registers a session_id
36
+ * first becomes its legitimate owner) -- an attacker who wins that race before the real
37
+ * session ever registers still prevails. This is a real, disclosed, accepted residual limit,
38
+ * not oversold as "verified identity"; scribe's own prior art never solved caller identity
39
+ * either. Registering as soon as the extension's session_start hook fires (before any
40
+ * Focus-mutating call could plausibly happen) shrinks this window to something small, not
41
+ * zero.
42
+ *
43
+ * actor/source free-text fields are explicitly NOT in scope here -- they remain audit-trail
44
+ * labeling only, never a security boundary, matching the conclusion already reached when this
45
+ * task was deferred from add-a-generic-mutation-event-log-to-the-papyrus-artifactstor-at6h.
46
+ */
47
+
48
+ export function assertValidSessionId(sessionId: string): void {
49
+ if (typeof sessionId !== "string" || sessionId.length === 0) throw new Error("session_id is required");
50
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * modules/session-identity.ts — the session-identity domain as a registered Papyrus-native
3
+ * module. Deliberately self-contained: no artifact/task infrastructure imports, matching the
4
+ * precedent set by modules/logs.ts. See domain/session-identity.ts for the design rationale.
5
+ */
6
+ import type { OperationDefinition } from "../module-registry.ts";
7
+ import type { SessionIdentity } from "../session-identity-service.ts";
8
+
9
+ const MODULE_ID = "session-identity";
10
+
11
+ type OperationInput = Record<string, unknown>;
12
+
13
+ function string(input: OperationInput, key: string): string {
14
+ const value = input[key];
15
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${key} is required`);
16
+ return value;
17
+ }
18
+
19
+ function optionalString(input: OperationInput, key: string): string | undefined {
20
+ const value = input[key];
21
+ if (value === undefined) return undefined;
22
+ if (typeof value !== "string") throw new Error(`${key} must be a string`);
23
+ return value;
24
+ }
25
+
26
+ /** This module's own operation names, the single source of truth src/service.ts's EXPECTED_OPERATION_NAMES spreads in rather than re-listing by hand. */
27
+ export const SESSION_IDENTITY_OPERATION_NAMES = ["session.register", "session.release"] as const;
28
+
29
+ /** Registers every session.* operation against one SessionIdentity instance. */
30
+ export function sessionIdentityOperations(sessionIdentity: SessionIdentity): OperationDefinition[] {
31
+ const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
32
+ name, moduleId: MODULE_ID, execute,
33
+ });
34
+ return [
35
+ define("session.register", (input: OperationInput) => sessionIdentity.register(string(input, "session_id"))),
36
+ define("session.release", (input: OperationInput) => sessionIdentity.release(string(input, "session_id"), optionalString(input, "session_secret"))),
37
+ ];
38
+ }
@@ -23,6 +23,7 @@ import type { TaskEventContext, TaskEventDirection } from "../domain/task-event.
23
23
  import type { TaskViewMode } from "../domain/task-scope.ts";
24
24
  import type { OperationDefinition } from "../module-registry.ts";
25
25
  import type { ArtifactStore } from "../ports/artifact-store.ts";
26
+ import type { SessionIdentity } from "../session-identity-service.ts";
26
27
  import { taskContext } from "../task-context.ts";
27
28
  import { projectTaskExecution } from "../task-execution.ts";
28
29
  import { Tasks, type TaskStatus } from "../task-service.ts";
@@ -89,13 +90,20 @@ export const TASKS_OPERATION_NAMES = [
89
90
  "tasks.scope", "tasks.set_scope", "tasks.assign_project", "tasks.active", "tasks.focused", "tasks.focus",
90
91
  "tasks.pause", "tasks.unpause", "tasks.clear_focus", "tasks.start", "tasks.submit", "tasks.complete",
91
92
  "tasks.run_gates", "tasks.set_checklist", "tasks.context", "tasks.reject", "tasks.retry", "tasks.cancel",
92
- "tasks.depend", "tasks.undepend", "tasks.contain", "tasks.uncontain",
93
+ "tasks.depend", "tasks.undepend", "tasks.contain", "tasks.uncontain", "tasks.reap_stale_focus",
93
94
  ] as const;
94
95
 
95
- export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore): OperationDefinition[] {
96
+ export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore, sessionIdentity: SessionIdentity): OperationDefinition[] {
96
97
  const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
97
98
  name, moduleId: MODULE_ID, execute,
98
99
  });
100
+ // Enforced only for the operations where session_id is BEHAVIOR-affecting (it selects
101
+ // which Task Focus row is mutated), not for every session_id-carrying operation -- see
102
+ // domain/session-identity.ts. A session_id with no registered identity passes through
103
+ // unchanged (opt-in armor).
104
+ const guardFocusMutation = (input: OperationInput): void => {
105
+ sessionIdentity.assertAuthorized(eventContext(input).sessionId, optionalString(input, "session_secret"));
106
+ };
99
107
  return [
100
108
  define("tasks.create", (input: OperationInput) => tasks.create({
101
109
  title: string(input, "title"),
@@ -139,10 +147,11 @@ export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore): Operati
139
147
  )),
140
148
  define("tasks.active", (input: OperationInput) => tasks.active(taskFilter(input))),
141
149
  define("tasks.focused", (input: OperationInput) => tasks.focused(taskFilter(input))),
142
- define("tasks.focus", (input: OperationInput) => tasks.focus(string(input, "id"), eventContext(input))),
143
- define("tasks.pause", (input: OperationInput) => tasks.pauseFocus(eventContext(input))),
144
- define("tasks.unpause", (input: OperationInput) => tasks.unpauseFocus(eventContext(input))),
145
- define("tasks.clear_focus", (input: OperationInput) => tasks.clearFocus(eventContext(input))),
150
+ define("tasks.focus", (input: OperationInput) => { guardFocusMutation(input); return tasks.focus(string(input, "id"), eventContext(input)); }),
151
+ define("tasks.pause", (input: OperationInput) => { guardFocusMutation(input); return tasks.pauseFocus(eventContext(input)); }),
152
+ define("tasks.unpause", (input: OperationInput) => { guardFocusMutation(input); return tasks.unpauseFocus(eventContext(input)); }),
153
+ define("tasks.clear_focus", (input: OperationInput) => { guardFocusMutation(input); return tasks.clearFocus(eventContext(input)); }),
154
+ define("tasks.reap_stale_focus", () => ({ removed: tasks.reapStaleFocus() })),
146
155
  define("tasks.start", (input: OperationInput) => tasks.transition(string(input, "id"), "start", eventContext(input))),
147
156
  define("tasks.submit", (input: OperationInput) => tasks.transition(string(input, "id"), "submit", eventContext(input))),
148
157
  define("tasks.complete", (input: OperationInput) => tasks.completeAsync(string(input, "id"), eventContext(input))),
@@ -0,0 +1,9 @@
1
+ import type { SessionIdentityRecord, SessionIdentityStore as DaemonKitSessionIdentityStore } from "@danypops/daemon-kit/session-identity";
2
+
3
+ /**
4
+ * Papyrus's persistence port for @danypops/daemon-kit's storage-agnostic session-identity
5
+ * primitive -- re-exported under this project's own port naming convention (src/ports/*)
6
+ * rather than importing the daemon-kit interface name directly at every call site.
7
+ */
8
+ export type SessionIdentityStore = DaemonKitSessionIdentityStore;
9
+ export type { SessionIdentityRecord };
@@ -30,6 +30,8 @@ export interface TaskFocusStore {
30
30
  clear(taskId?: string, scope?: string): void;
31
31
  /** Clears this task's Focus in every scope (session), not just one — for lifecycle events (e.g. cancel) that are not scoped to a single caller. */
32
32
  clearEverywhere(taskId: string): void;
33
+ /** Deletes every Focus row whose updatedAt is strictly before olderThanIso (see TASK_FOCUS_STALE_AFTER_MS). Returns how many rows were removed. */
34
+ reapStale(olderThanIso: string): number;
33
35
  }
34
36
 
35
37
  export class InMemoryTaskFocusStore implements TaskFocusStore {
@@ -74,6 +76,14 @@ export class InMemoryTaskFocusStore implements TaskFocusStore {
74
76
  }
75
77
  }
76
78
 
79
+ reapStale(olderThanIso: string): number {
80
+ let removed = 0;
81
+ for (const [key, focus] of this.state) {
82
+ if (focus.updatedAt < olderThanIso) { this.state.delete(key); removed++; }
83
+ }
84
+ return removed;
85
+ }
86
+
77
87
  private evictOldest(): void {
78
88
  let oldestKey: string | undefined;
79
89
  let oldestAt: string | undefined;
package/src/service.ts CHANGED
@@ -8,6 +8,7 @@ import { SQLiteGraphProjectionStore } from "./adapters/sqlite-graph-projection-s
8
8
  import { SQLiteTaskFocusStore } from "./adapters/sqlite-task-focus-store.ts";
9
9
  import { SQLiteTaskEventStore } from "./adapters/sqlite-task-event-store.ts";
10
10
  import { SQLiteTaskScopeStore } from "./adapters/sqlite-task-scope-store.ts";
11
+ import { SQLiteSessionIdentityStore } from "./adapters/sqlite-session-identity-store.ts";
11
12
  import type { CreateArtifactInput } from "./domain/artifact.ts";
12
13
  import { AuthorityRegistry, AuthorizedArtifactWriter, type AuthorityClaim } from "./authority-registry.ts";
13
14
  import type { TaskEventContext } from "./domain/task-event.ts";
@@ -24,6 +25,7 @@ import {
24
25
  import { Notes, NOTE_SUBTYPE } from "./note-service.ts";
25
26
  import { Logs } from "./log-service.ts";
26
27
  import { SQLiteLogStore } from "./adapters/sqlite-log-store.ts";
28
+ import { SessionIdentity, InvalidSessionSecretError } from "./session-identity-service.ts";
27
29
  import { OperationRegistry } from "./module-registry.ts";
28
30
  import { docsOperations, DOCS_OPERATION_NAMES } from "./modules/docs.ts";
29
31
  import { graphProjectionOperations, GRAPH_PROJECTION_OPERATION_NAMES } from "./modules/graph-projection.ts";
@@ -31,6 +33,7 @@ import { logsOperations, LOGS_OPERATION_NAMES } from "./modules/logs.ts";
31
33
  import { notesOperations, NOTES_OPERATION_NAMES } from "./modules/notes.ts";
32
34
  import { rulesOperations, RULES_OPERATION_NAMES } from "./modules/rules.ts";
33
35
  import { skillsOperations, SKILLS_OPERATION_NAMES } from "./modules/skills.ts";
36
+ import { sessionIdentityOperations, SESSION_IDENTITY_OPERATION_NAMES } from "./modules/session-identity.ts";
34
37
  import { tasksOperations, TASKS_OPERATION_NAMES } from "./modules/tasks.ts";
35
38
 
36
39
  /**
@@ -68,6 +71,7 @@ export const EXPECTED_OPERATION_NAMES = [
68
71
  ...SKILLS_OPERATION_NAMES,
69
72
  ...GRAPH_PROJECTION_OPERATION_NAMES,
70
73
  ...LOGS_OPERATION_NAMES,
74
+ ...SESSION_IDENTITY_OPERATION_NAMES,
71
75
  ] as const;
72
76
 
73
77
  export type OperationName = typeof EXPECTED_OPERATION_NAMES[number];
@@ -77,6 +81,7 @@ type OperationHandler = (input: OperationInput) => unknown;
77
81
  export class UnknownOperationError extends Error {}
78
82
  export class MigrationRequiredError extends Error {}
79
83
  export class PayloadTooLargeError extends Error {}
84
+ export { InvalidSessionSecretError };
80
85
 
81
86
  function string(input: OperationInput, key: string): string {
82
87
  const value = input[key];
@@ -166,6 +171,8 @@ export interface PapyrusService {
166
171
  execute(operation: string, input?: OperationInput): Promise<unknown>;
167
172
  checkpoint(): void;
168
173
  optimize(): void;
174
+ /** Time-based Task Focus reclamation (see Tasks.reapStaleFocus); returns how many rows were removed, for daemon logging. */
175
+ reapStaleFocus(): number;
169
176
  close(): void;
170
177
  }
171
178
 
@@ -313,6 +320,7 @@ function handlers(
313
320
  "tasks.undepend": forwardToModule("tasks.undepend"),
314
321
  "tasks.contain": forwardToModule("tasks.contain"),
315
322
  "tasks.uncontain": forwardToModule("tasks.uncontain"),
323
+ "tasks.reap_stale_focus": forwardToModule("tasks.reap_stale_focus"),
316
324
  "docs.create": forwardToModule("docs.create"),
317
325
  "docs.list": forwardToModule("docs.list"),
318
326
  "docs.show": forwardToModule("docs.show"),
@@ -366,6 +374,8 @@ function handlers(
366
374
  "graph_projection.checkpoint": forwardToModule("graph_projection.checkpoint"),
367
375
  "logs.append": forwardToModule("logs.append"),
368
376
  "logs.query": forwardToModule("logs.query"),
377
+ "session.register": forwardToModule("session.register"),
378
+ "session.release": forwardToModule("session.release"),
369
379
  };
370
380
  }
371
381
 
@@ -381,11 +391,13 @@ export function createPapyrusService(path: string): PapyrusService {
381
391
  const projections = new SQLiteGraphProjectionStore(db);
382
392
  const artifactScopes = new SQLiteArtifactScopeStore(db);
383
393
  const logs = new Logs(new SQLiteLogStore(db));
394
+ const sessionIdentity = new SessionIdentity(new SQLiteSessionIdentityStore(db));
384
395
  const authority = createAuthorityRegistry();
385
396
  const moduleRegistry = new OperationRegistry();
386
397
  moduleRegistry.registerAll(notesOperations(notes));
387
398
  moduleRegistry.registerAll(logsOperations(logs));
388
- moduleRegistry.registerAll(tasksOperations(tasks, artifacts));
399
+ moduleRegistry.registerAll(sessionIdentityOperations(sessionIdentity));
400
+ moduleRegistry.registerAll(tasksOperations(tasks, artifacts, sessionIdentity));
389
401
  moduleRegistry.registerAll(docsOperations(artifacts, artifactScopes, authority));
390
402
  moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes));
391
403
  moduleRegistry.registerAll(skillsOperations({ artifacts, events, scopes, artifactScopes, authority }));
@@ -408,6 +420,7 @@ export function createPapyrusService(path: string): PapyrusService {
408
420
  },
409
421
  checkpoint: () => { db.exec("PRAGMA wal_checkpoint(PASSIVE)"); },
410
422
  optimize: () => { db.exec("PRAGMA optimize"); },
423
+ reapStaleFocus: () => tasks.reapStaleFocus(),
411
424
  close: () => {
412
425
  db.exec("PRAGMA optimize");
413
426
  db.close();
@@ -467,7 +480,7 @@ export function createApp(deps: { service: PapyrusService; token: string }): { f
467
480
  }
468
481
  return json({ result: await deps.service.execute(body.op, input as OperationInput) });
469
482
  } catch (error) {
470
- const status = error instanceof PayloadTooLargeError ? 413 : error instanceof UnknownOperationError ? 404 : 400;
483
+ const status = error instanceof PayloadTooLargeError ? 413 : error instanceof UnknownOperationError ? 404 : error instanceof InvalidSessionSecretError ? 403 : 400;
471
484
  return json({ error: error instanceof Error ? error.message : String(error) }, { status });
472
485
  }
473
486
  }
@@ -0,0 +1,55 @@
1
+ import { isSessionRegistered, registerSessionIdentity, releaseSessionIdentity, verifySessionSecret } from "@danypops/daemon-kit/session-identity";
2
+ import { assertValidSessionId } from "./domain/session-identity.ts";
3
+ import type { SessionIdentityStore } from "./ports/session-identity-store.ts";
4
+
5
+ export interface RegisterSessionIdentityResult {
6
+ sessionId: string;
7
+ secret: string;
8
+ }
9
+
10
+ /** Thrown by assertSessionAuthorized when a session_id has a registered identity but the caller did not present a matching session_secret. A distinguishable type so service.ts can map it to HTTP 403, separate from generic validation's 400. */
11
+ export class InvalidSessionSecretError extends Error {}
12
+
13
+ /**
14
+ * Thin Papyrus-side wrapper over @danypops/daemon-kit's storage-agnostic session-identity
15
+ * primitive: validates input shape, binds it to Papyrus's own SQLite-backed store, and
16
+ * exposes the exact three operations Task Focus enforcement needs (see
17
+ * assertAuthorizedForFocus in src/modules/tasks.ts). See domain/session-identity.ts for the
18
+ * full design rationale and its explicitly accepted residual limits.
19
+ */
20
+ export class SessionIdentity {
21
+ constructor(private readonly store: SessionIdentityStore) {}
22
+
23
+ register(sessionId: string): RegisterSessionIdentityResult {
24
+ assertValidSessionId(sessionId);
25
+ return registerSessionIdentity(this.store, sessionId);
26
+ }
27
+
28
+ release(sessionId: string, secret: string | undefined): { released: boolean } {
29
+ assertValidSessionId(sessionId);
30
+ const wasRegistered = isSessionRegistered(this.store, sessionId);
31
+ releaseSessionIdentity(this.store, sessionId, secret);
32
+ return { released: wasRegistered && !isSessionRegistered(this.store, sessionId) };
33
+ }
34
+
35
+ isRegistered(sessionId: string): boolean {
36
+ return isSessionRegistered(this.store, sessionId);
37
+ }
38
+
39
+ verify(sessionId: string, secret: string | undefined): boolean {
40
+ return verifySessionSecret(this.store, sessionId, secret);
41
+ }
42
+
43
+ /**
44
+ * The enforcement point for a Focus-mutating operation: opt-in armor, matching
45
+ * domain/session-identity.ts's design -- a sessionId with no registered identity passes
46
+ * through unauthenticated exactly as before (undefined sessionId included, since that maps
47
+ * to Task Focus's "global" scope, never armored). Only once a sessionId is registered does
48
+ * a matching session_secret become mandatory.
49
+ */
50
+ assertAuthorized(sessionId: string | undefined, secret: string | undefined): void {
51
+ if (sessionId === undefined) return;
52
+ if (!this.isRegistered(sessionId)) return;
53
+ if (!this.verify(sessionId, secret)) throw new InvalidSessionSecretError(`session "${sessionId}" is registered; a valid session_secret is required to mutate its Task Focus`);
54
+ }
55
+ }
@@ -5,6 +5,7 @@ import {
5
5
  TASK_EXECUTION_MAX_NODES,
6
6
  TASK_LABEL_MAX_COUNT,
7
7
  TASK_LABEL_MAX_LENGTH,
8
+ TASK_FOCUS_STALE_AFTER_MS,
8
9
  TASK_SCOPE_MAX_TASKS,
9
10
  TASK_TITLE_MAX_LENGTH,
10
11
  } from "./constants.ts";
@@ -383,6 +384,19 @@ export class Tasks {
383
384
  });
384
385
  }
385
386
 
387
+ /**
388
+ * Time-based reclamation of Focus scopes nobody has touched in TASK_FOCUS_STALE_AFTER_MS,
389
+ * independent of and in addition to the TASK_FOCUS_MAX_SCOPES hard cap -- see
390
+ * clean-up-stale-per-session-task-focus-rows-on-real-session-l-9i7s and constants.ts's
391
+ * comment on why this is deliberately not driven by session_start/session_shutdown.
392
+ * No task-lifecycle event is appended: this is daemon housekeeping, not a caller-driven
393
+ * mutation, and there is no longer a specific session/actor to attribute it to.
394
+ */
395
+ reapStaleFocus(now: () => string = () => new Date().toISOString()): number {
396
+ const cutoff = new Date(new Date(now()).getTime() - TASK_FOCUS_STALE_AFTER_MS).toISOString();
397
+ return this.focusStore.reapStale(cutoff);
398
+ }
399
+
386
400
  transition(id: string, action: TaskTransition, context: TaskEventContext = {}): Artifact {
387
401
  return this.events.atomic(() => {
388
402
  const task = this.require(id);