kankaku-pi 1.0.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/LICENSE +21 -0
- package/README.md +1438 -0
- package/dist/adapters/cached-catalog.d.ts +42 -0
- package/dist/adapters/cached-catalog.js +121 -0
- package/dist/adapters/export-writer.d.ts +13 -0
- package/dist/adapters/export-writer.js +28 -0
- package/dist/adapters/file-modes.d.ts +20 -0
- package/dist/adapters/file-modes.js +34 -0
- package/dist/adapters/hub-actions.d.ts +35 -0
- package/dist/adapters/hub-actions.js +70 -0
- package/dist/adapters/hub-credentials.d.ts +35 -0
- package/dist/adapters/hub-credentials.js +58 -0
- package/dist/adapters/jsonl-work-log.d.ts +20 -0
- package/dist/adapters/jsonl-work-log.js +62 -0
- package/dist/adapters/kankaku-dir.d.ts +38 -0
- package/dist/adapters/kankaku-dir.js +85 -0
- package/dist/adapters/lazy-jsonl-work-log.d.ts +17 -0
- package/dist/adapters/lazy-jsonl-work-log.js +31 -0
- package/dist/adapters/pocketbase-catalog.d.ts +16 -0
- package/dist/adapters/pocketbase-catalog.js +56 -0
- package/dist/adapters/pocketbase-client.d.ts +81 -0
- package/dist/adapters/pocketbase-client.js +148 -0
- package/dist/adapters/pocketbase-sink.d.ts +53 -0
- package/dist/adapters/pocketbase-sink.js +181 -0
- package/dist/adapters/project-config.d.ts +42 -0
- package/dist/adapters/project-config.js +108 -0
- package/dist/adapters/report-data.d.ts +12 -0
- package/dist/adapters/report-data.js +8 -0
- package/dist/adapters/report-views.d.ts +45 -0
- package/dist/adapters/report-views.js +73 -0
- package/dist/adapters/report.d.ts +112 -0
- package/dist/adapters/report.js +236 -0
- package/dist/adapters/sync-runner.d.ts +114 -0
- package/dist/adapters/sync-runner.js +273 -0
- package/dist/adapters/sync-state-store.d.ts +62 -0
- package/dist/adapters/sync-state-store.js +188 -0
- package/dist/config.d.ts +168 -0
- package/dist/config.js +392 -0
- package/dist/domain/ancestry-match.d.ts +49 -0
- package/dist/domain/ancestry-match.js +82 -0
- package/dist/domain/client-label.d.ts +28 -0
- package/dist/domain/client-label.js +44 -0
- package/dist/domain/day.d.ts +2 -0
- package/dist/domain/day.js +8 -0
- package/dist/domain/export.d.ts +38 -0
- package/dist/domain/export.js +68 -0
- package/dist/domain/hub-entry.d.ts +234 -0
- package/dist/domain/hub-entry.js +265 -0
- package/dist/domain/index.d.ts +19 -0
- package/dist/domain/index.js +19 -0
- package/dist/domain/intervals.d.ts +17 -0
- package/dist/domain/intervals.js +43 -0
- package/dist/domain/registry-health.d.ts +49 -0
- package/dist/domain/registry-health.js +58 -0
- package/dist/domain/segment-rule.d.ts +10 -0
- package/dist/domain/segment-rule.js +1 -0
- package/dist/domain/subagent-profile.d.ts +278 -0
- package/dist/domain/subagent-profile.js +418 -0
- package/dist/domain/sync-plan.d.ts +151 -0
- package/dist/domain/sync-plan.js +196 -0
- package/dist/domain/task-view.d.ts +117 -0
- package/dist/domain/task-view.js +428 -0
- package/dist/domain/work-record.d.ts +236 -0
- package/dist/domain/work-record.js +91 -0
- package/dist/domain/work-target.d.ts +101 -0
- package/dist/domain/work-target.js +149 -0
- package/dist/domain/work-tracker.d.ts +90 -0
- package/dist/domain/work-tracker.js +405 -0
- package/dist/hub/index.d.ts +25 -0
- package/dist/hub/index.js +25 -0
- package/dist/ports/catalog.d.ts +31 -0
- package/dist/ports/catalog.js +1 -0
- package/dist/ports/clock.d.ts +3 -0
- package/dist/ports/clock.js +1 -0
- package/dist/ports/index.d.ts +11 -0
- package/dist/ports/index.js +1 -0
- package/dist/ports/inflight-store.d.ts +15 -0
- package/dist/ports/inflight-store.js +1 -0
- package/dist/ports/process-registry.d.ts +72 -0
- package/dist/ports/process-registry.js +1 -0
- package/dist/ports/work-log.d.ts +14 -0
- package/dist/ports/work-log.js +1 -0
- package/dist/ports/work-sink.d.ts +39 -0
- package/dist/ports/work-sink.js +1 -0
- package/package.json +66 -0
- package/src/adapters/agent-info.ts +86 -0
- package/src/adapters/ancestry.ts +260 -0
- package/src/adapters/cached-catalog.ts +147 -0
- package/src/adapters/export-writer.ts +33 -0
- package/src/adapters/file-inflight-store.ts +115 -0
- package/src/adapters/file-modes.ts +35 -0
- package/src/adapters/hub-actions.ts +82 -0
- package/src/adapters/hub-credentials.ts +95 -0
- package/src/adapters/jsonl-work-log.ts +67 -0
- package/src/adapters/kankaku-command.ts +717 -0
- package/src/adapters/kankaku-dir.ts +102 -0
- package/src/adapters/lazy-file-inflight-store.ts +43 -0
- package/src/adapters/lazy-jsonl-work-log.ts +39 -0
- package/src/adapters/machine-process-registry.ts +256 -0
- package/src/adapters/panel/kankaku-panel.ts +419 -0
- package/src/adapters/panel/panel-items.ts +87 -0
- package/src/adapters/panel/panel-lines.ts +13 -0
- package/src/adapters/panel/panel-theme.ts +32 -0
- package/src/adapters/panel/screens/about.ts +69 -0
- package/src/adapters/panel/screens/doctor.ts +89 -0
- package/src/adapters/panel/screens/export.ts +123 -0
- package/src/adapters/panel/screens/report.ts +143 -0
- package/src/adapters/panel/screens/sync.ts +136 -0
- package/src/adapters/panel/screens/target.ts +384 -0
- package/src/adapters/pi-tracker.ts +753 -0
- package/src/adapters/pocketbase-catalog.ts +89 -0
- package/src/adapters/pocketbase-client.ts +197 -0
- package/src/adapters/pocketbase-sink.ts +236 -0
- package/src/adapters/process-identity-memo.ts +102 -0
- package/src/adapters/process-identity.ts +162 -0
- package/src/adapters/project-config.ts +116 -0
- package/src/adapters/report-data.ts +13 -0
- package/src/adapters/report-views.ts +98 -0
- package/src/adapters/report.ts +335 -0
- package/src/adapters/session-client.ts +116 -0
- package/src/adapters/session-dir.ts +28 -0
- package/src/adapters/session-target.ts +431 -0
- package/src/adapters/status-bar.ts +86 -0
- package/src/adapters/subagent-startup.ts +66 -0
- package/src/adapters/sync-runner.ts +340 -0
- package/src/adapters/sync-state-store.ts +227 -0
- package/src/adapters/target-picker.ts +127 -0
- package/src/config.ts +536 -0
- package/src/domain/ancestry-match.ts +84 -0
- package/src/domain/client-label.ts +56 -0
- package/src/domain/day.ts +8 -0
- package/src/domain/export.ts +107 -0
- package/src/domain/hub-entry.ts +433 -0
- package/src/domain/index.ts +19 -0
- package/src/domain/intervals.ts +53 -0
- package/src/domain/panel-model.ts +270 -0
- package/src/domain/registry-health.ts +87 -0
- package/src/domain/segment-rule.ts +10 -0
- package/src/domain/subagent-profile.ts +495 -0
- package/src/domain/sync-plan.ts +266 -0
- package/src/domain/task-view.ts +526 -0
- package/src/domain/work-record.ts +320 -0
- package/src/domain/work-target.ts +234 -0
- package/src/domain/work-tracker.ts +485 -0
- package/src/extension.ts +346 -0
- package/src/hub/index.ts +25 -0
- package/src/ports/catalog.ts +33 -0
- package/src/ports/clock.ts +3 -0
- package/src/ports/index.ts +11 -0
- package/src/ports/inflight-store.ts +16 -0
- package/src/ports/process-registry.ts +75 -0
- package/src/ports/work-log.ts +15 -0
- package/src/ports/work-sink.ts +35 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Client, HubTask, Project, WorkTarget } from "../domain/work-target.ts";
|
|
3
|
+
|
|
4
|
+
const SKIP_OPTION = "— skip —";
|
|
5
|
+
const NO_PROJECT_OPTION = "(no project)";
|
|
6
|
+
|
|
7
|
+
export interface PickerCatalog {
|
|
8
|
+
clients: Client[];
|
|
9
|
+
projects: Project[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type PickResult = { kind: "picked"; target: WorkTarget } | { kind: "skipped" };
|
|
13
|
+
|
|
14
|
+
interface LabeledOption<T> {
|
|
15
|
+
label: string;
|
|
16
|
+
item: T;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build `label -> item` options, sorted by name. When two items share the
|
|
21
|
+
* same name, disambiguate every colliding label by appending ` (code)` so
|
|
22
|
+
* every option maps back to exactly one id.
|
|
23
|
+
*/
|
|
24
|
+
function labelOptions<T extends { name: string; code?: string }>(items: T[]): LabeledOption<T>[] {
|
|
25
|
+
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
|
|
26
|
+
const nameCounts = new Map<string, number>();
|
|
27
|
+
for (const item of sorted) {
|
|
28
|
+
nameCounts.set(item.name, (nameCounts.get(item.name) ?? 0) + 1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return sorted.map((item) => {
|
|
32
|
+
const collides = (nameCounts.get(item.name) ?? 0) > 1;
|
|
33
|
+
const label = collides && item.code ? `${item.name} (${item.code})` : item.name;
|
|
34
|
+
return { label, item };
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Run the client/project picker (`ctx.ui.select`) against an already-loaded
|
|
40
|
+
* catalog snapshot. Pure UI interaction: no network, no persistence — the
|
|
41
|
+
* caller (`session-target.ts`) decides what to do with the result.
|
|
42
|
+
*
|
|
43
|
+
* Declining at either step — choosing "— skip —" or dismissing the dialog
|
|
44
|
+
* (`undefined`) — cancels the whole pick, not just that step.
|
|
45
|
+
*/
|
|
46
|
+
export async function pickTarget(ctx: ExtensionContext, catalog: PickerCatalog): Promise<PickResult> {
|
|
47
|
+
const pickableClients = catalog.clients.filter((client) => client.active && !client.unassigned);
|
|
48
|
+
const clientOptions = labelOptions(pickableClients);
|
|
49
|
+
|
|
50
|
+
const clientChoice = await ctx.ui.select("kankaku — client", [...clientOptions.map((option) => option.label), SKIP_OPTION]);
|
|
51
|
+
if (clientChoice === undefined || clientChoice === SKIP_OPTION) return { kind: "skipped" };
|
|
52
|
+
|
|
53
|
+
const client = clientOptions.find((option) => option.label === clientChoice)?.item;
|
|
54
|
+
if (!client) return { kind: "skipped" };
|
|
55
|
+
|
|
56
|
+
const pickableProjects = catalog.projects.filter((project) => project.active && project.clientId === client.id);
|
|
57
|
+
const projectOptions = labelOptions(pickableProjects);
|
|
58
|
+
|
|
59
|
+
const projectChoice = await ctx.ui.select("kankaku — project", [
|
|
60
|
+
...projectOptions.map((option) => option.label),
|
|
61
|
+
NO_PROJECT_OPTION,
|
|
62
|
+
SKIP_OPTION,
|
|
63
|
+
]);
|
|
64
|
+
if (projectChoice === undefined || projectChoice === SKIP_OPTION) return { kind: "skipped" };
|
|
65
|
+
|
|
66
|
+
const project = projectChoice === NO_PROJECT_OPTION ? undefined : projectOptions.find((option) => option.label === projectChoice)?.item;
|
|
67
|
+
|
|
68
|
+
const target: WorkTarget = {
|
|
69
|
+
clientId: client.id,
|
|
70
|
+
clientCode: client.code,
|
|
71
|
+
clientName: client.name,
|
|
72
|
+
...(project !== undefined
|
|
73
|
+
? {
|
|
74
|
+
projectId: project.id,
|
|
75
|
+
...(project.code !== undefined ? { projectCode: project.code } : {}),
|
|
76
|
+
projectName: project.name,
|
|
77
|
+
}
|
|
78
|
+
: {}),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return { kind: "picked", target };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type PickHubTaskResult = { kind: "picked"; task: HubTask } | { kind: "skipped" } | { kind: "empty" };
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build `label -> task` options for {@link pickHubTask}, sorted by title.
|
|
88
|
+
* When two tasks share the same title, disambiguate every colliding label
|
|
89
|
+
* with the task's `externalRef` when it has one, or its bare id otherwise —
|
|
90
|
+
* unlike {@link labelOptions}, a hub task has no `code`, and every task
|
|
91
|
+
* needs a disambiguator, not just the ones lucky enough to have one.
|
|
92
|
+
*/
|
|
93
|
+
function labelTaskOptions(tasks: HubTask[]): LabeledOption<HubTask>[] {
|
|
94
|
+
const sorted = [...tasks].sort((a, b) => a.title.localeCompare(b.title));
|
|
95
|
+
const titleCounts = new Map<string, number>();
|
|
96
|
+
for (const task of sorted) {
|
|
97
|
+
titleCounts.set(task.title, (titleCounts.get(task.title) ?? 0) + 1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return sorted.map((task) => {
|
|
101
|
+
const collides = (titleCounts.get(task.title) ?? 0) > 1;
|
|
102
|
+
const label = collides ? `${task.title} (${task.externalRef ?? task.id})` : task.title;
|
|
103
|
+
return { label, item: task };
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Run the hub-task picker (`ctx.ui.select`) for `projectId`'s open/doing
|
|
109
|
+
* tasks. Pure UI interaction: no network, no persistence — the caller
|
|
110
|
+
* (`session-target.ts#pickTask`) decides what to do with the result.
|
|
111
|
+
* `{ kind: "empty" }` is returned without ever prompting when the project
|
|
112
|
+
* has no open/doing task, so the caller can tell "nothing to pick from"
|
|
113
|
+
* apart from "the user skipped".
|
|
114
|
+
*/
|
|
115
|
+
export async function pickHubTask(ctx: ExtensionContext, tasks: HubTask[], projectId: string): Promise<PickHubTaskResult> {
|
|
116
|
+
const pickable = tasks.filter((task) => task.status !== "done" && task.projectId === projectId);
|
|
117
|
+
if (pickable.length === 0) return { kind: "empty" };
|
|
118
|
+
|
|
119
|
+
const taskOptions = labelTaskOptions(pickable);
|
|
120
|
+
const choice = await ctx.ui.select("kankaku — task", [...taskOptions.map((option) => option.label), SKIP_OPTION]);
|
|
121
|
+
if (choice === undefined || choice === SKIP_OPTION) return { kind: "skipped" };
|
|
122
|
+
|
|
123
|
+
const task = taskOptions.find((option) => option.label === choice)?.item;
|
|
124
|
+
if (!task) return { kind: "skipped" };
|
|
125
|
+
|
|
126
|
+
return { kind: "picked", task };
|
|
127
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
import type { SegmentRule } from "./domain/segment-rule.ts";
|
|
2
|
+
import type { PromptPrivacyMode } from "./domain/hub-entry.ts";
|
|
3
|
+
import { BUILTIN_SUBAGENT_PROFILES, buildConfiguredProfile, matchesAnyMarker } from "./domain/subagent-profile.ts";
|
|
4
|
+
import type { ChildEnvMarker, SubagentProfile } from "./domain/subagent-profile.ts";
|
|
5
|
+
|
|
6
|
+
export interface KankakuConfig {
|
|
7
|
+
/** Directory for the work log, relative to the project cwd unless absolute. */
|
|
8
|
+
dir: string;
|
|
9
|
+
/** Tool names whose execution span counts as waiting time. */
|
|
10
|
+
interactiveTools: string[];
|
|
11
|
+
/**
|
|
12
|
+
* Every active {@link SubagentProfile} (ADR 0020): the built-ins
|
|
13
|
+
* (gentle-pi, pi's bundled reference example, pi-subagents) plus, when
|
|
14
|
+
* `KANKAKU_SUBAGENT_TOOLS`/`KANKAKU_SUBAGENT_CHILD_ENV` are set, one
|
|
15
|
+
* additional `"configured"` profile — always additive, never replacing
|
|
16
|
+
* gentle-pi's own recognition. `SUBAGENT_TOOL` is no longer a hardcoded
|
|
17
|
+
* constant; `domain/work-tracker.ts` matches subagent tool calls against
|
|
18
|
+
* the union of every profile's `toolNames`.
|
|
19
|
+
*/
|
|
20
|
+
subagentProfiles: SubagentProfile[];
|
|
21
|
+
/** Rules that tag a tool execution's span under a named segment (e.g. `review`). */
|
|
22
|
+
segmentRules: SegmentRule[];
|
|
23
|
+
/** Default billing client for this project, from `KANKAKU_CLIENT`. See `domain/client-label.ts`. */
|
|
24
|
+
client?: string;
|
|
25
|
+
/**
|
|
26
|
+
* C2 (CRITICAL fix): every `KANKAKU_SUBAGENT_CHILD_ENV` entry rejected by
|
|
27
|
+
* {@link validateSubagentChildEnvMarkers} — a marker name that looks like
|
|
28
|
+
* an ambient pi/shell/OS/npm environment variable, not a genuine
|
|
29
|
+
* child-only marker. Always present (empty when nothing was configured,
|
|
30
|
+
* or everything configured was accepted), so a caller never has to guard
|
|
31
|
+
* against it being `undefined`. `/kankaku doctor` and a one-time
|
|
32
|
+
* `ctx.ui.notify` are expected to surface this (see `adapters/pi-tracker.ts`).
|
|
33
|
+
*/
|
|
34
|
+
rejectedSubagentChildEnvMarkers: RejectedChildEnvMarker[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One `KANKAKU_SUBAGENT_CHILD_ENV` marker {@link validateSubagentChildEnvMarkers} rejected, with why. */
|
|
38
|
+
export interface RejectedChildEnvMarker {
|
|
39
|
+
name: string;
|
|
40
|
+
reason: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DEFAULT_DIR = ".kankaku";
|
|
44
|
+
const DEFAULT_INTERACTIVE_TOOLS = ["ask_user_question", "ask_user_choice"];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Default segment rule: with gentle-ai, the review-with-receipts step runs
|
|
48
|
+
* as `gentle-ai review ...` commands through the `bash` tool, so tag that
|
|
49
|
+
* span `review`.
|
|
50
|
+
*/
|
|
51
|
+
const DEFAULT_SEGMENT_RULES: SegmentRule[] = [{ tag: "review", tool: "bash", pattern: /\bgentle-ai review\b/ }];
|
|
52
|
+
|
|
53
|
+
/** Tags allowed for a segment rule: letters, digits, `_` and `-`, 1-32 chars. */
|
|
54
|
+
const SAFE_TAG = /^[A-Za-z0-9_-]{1,32}$/;
|
|
55
|
+
/** Property names that behave specially on a plain object; never usable as a tag. */
|
|
56
|
+
const RESERVED_TAGS = new Set(["__proto__", "constructor", "prototype"]);
|
|
57
|
+
|
|
58
|
+
function isSafeTag(tag: string): boolean {
|
|
59
|
+
return SAFE_TAG.test(tag) && !RESERVED_TAGS.has(tag);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parse `KANKAKU_SEGMENTS`, a `;`-separated list of `tag=tool:regex`
|
|
64
|
+
* entries (example: `review=bash:gentle-ai review;commit=bash:git commit`).
|
|
65
|
+
* Malformed entries (missing tag, tool or regex, an invalid regex source,
|
|
66
|
+
* or a tag that is not a safe identifier such as `__proto__`) are skipped
|
|
67
|
+
* rather than failing the whole variable.
|
|
68
|
+
*/
|
|
69
|
+
function parseSegmentRules(raw: string): SegmentRule[] {
|
|
70
|
+
const rules: SegmentRule[] = [];
|
|
71
|
+
|
|
72
|
+
for (const entry of raw.split(";")) {
|
|
73
|
+
const trimmed = entry.trim();
|
|
74
|
+
if (!trimmed) continue;
|
|
75
|
+
|
|
76
|
+
const eqIndex = trimmed.indexOf("=");
|
|
77
|
+
if (eqIndex <= 0) continue;
|
|
78
|
+
|
|
79
|
+
const tag = trimmed.slice(0, eqIndex).trim();
|
|
80
|
+
const rest = trimmed.slice(eqIndex + 1);
|
|
81
|
+
const colonIndex = rest.indexOf(":");
|
|
82
|
+
if (colonIndex <= 0) continue;
|
|
83
|
+
|
|
84
|
+
const tool = rest.slice(0, colonIndex).trim();
|
|
85
|
+
const regexSource = rest.slice(colonIndex + 1).trim();
|
|
86
|
+
if (!tag || !tool || !regexSource || !isSafeTag(tag)) continue;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
rules.push({ tag, tool, pattern: new RegExp(regexSource) });
|
|
90
|
+
} catch {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return rules;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* SUBAGENT-REQ-002: `KANKAKU_SUBAGENT_TOOLS`, a comma-separated list of
|
|
100
|
+
* additional tool names treated as subagent-launching spans, parsed with
|
|
101
|
+
* the exact same tolerant trim-and-filter-empty convention as
|
|
102
|
+
* `KANKAKU_INTERACTIVE_TOOLS` above.
|
|
103
|
+
*/
|
|
104
|
+
function parseSubagentTools(raw: string): string[] {
|
|
105
|
+
return raw
|
|
106
|
+
.split(",")
|
|
107
|
+
.map((tool) => tool.trim())
|
|
108
|
+
.filter((tool) => tool.length > 0);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* SUBAGENT-REQ-003: `KANKAKU_SUBAGENT_CHILD_ENV`, a `;`-separated list of
|
|
113
|
+
* `NAME=VALUE` or bare `NAME` child-process env markers — mirrors
|
|
114
|
+
* `KANKAKU_SEGMENTS`'s tolerant `;`-separated convention. A bare `NAME`
|
|
115
|
+
* marks presence-only (any non-empty value matches, e.g. pi-subagents'
|
|
116
|
+
* depth counter); `NAME=VALUE` requires an exact match. An entry with no
|
|
117
|
+
* name, or a trailing `=` with nothing after it, is malformed and skipped
|
|
118
|
+
* rather than failing the whole variable.
|
|
119
|
+
*/
|
|
120
|
+
function parseSubagentChildEnv(raw: string): ChildEnvMarker[] {
|
|
121
|
+
const markers: ChildEnvMarker[] = [];
|
|
122
|
+
|
|
123
|
+
for (const entry of raw.split(";")) {
|
|
124
|
+
const trimmed = entry.trim();
|
|
125
|
+
if (!trimmed) continue;
|
|
126
|
+
|
|
127
|
+
const eqIndex = trimmed.indexOf("=");
|
|
128
|
+
if (eqIndex === -1) {
|
|
129
|
+
const name = trimmed;
|
|
130
|
+
if (name) markers.push({ name });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const name = trimmed.slice(0, eqIndex).trim();
|
|
135
|
+
const value = trimmed.slice(eqIndex + 1).trim();
|
|
136
|
+
if (!name || !value) continue;
|
|
137
|
+
markers.push({ name, value });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return markers;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* C2 (CRITICAL fix, item 1): exact marker names pi sets on EVERY process it
|
|
145
|
+
* runs (verified: `dist/cli/setup.js:5` sets `PI_CODING_AGENT=true`,
|
|
146
|
+
* `dist/rpc-entry.js:6` sets `AI_AGENT=pi` — both unconditionally, not just
|
|
147
|
+
* for a subagent's child) or that are otherwise ordinary, ambient
|
|
148
|
+
* shell/OS/npm-lifecycle environment, never something a real child-only
|
|
149
|
+
* marker would legitimately reuse.
|
|
150
|
+
*/
|
|
151
|
+
const DENIED_MARKER_NAMES = new Set(["PI_CODING_AGENT", "AI_AGENT", "PATH", "HOME", "USER", "SHELL", "PWD", "CI", "LANG", "TMUX"]);
|
|
152
|
+
|
|
153
|
+
/** C2 item 1: namespace prefixes an ambient variable is overwhelmingly likely to fall under — a genuine child-only marker should never need one of these either. */
|
|
154
|
+
const DENIED_MARKER_PREFIXES = ["PI_", "TERM", "LC_", "NODE_", "NPM_", "KANKAKU_"];
|
|
155
|
+
|
|
156
|
+
/** What an environment variable name may look like; anything else is a typo, never a marker. */
|
|
157
|
+
const VALID_MARKER_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
158
|
+
|
|
159
|
+
/** `undefined` when `name` is an acceptable marker name; otherwise a human-readable reason it was rejected. Matching is case-insensitive — an env var name's case carries no meaning here. */
|
|
160
|
+
function deniedMarkerReason(name: string): string | undefined {
|
|
161
|
+
// A name that is not a valid environment variable name can never match a
|
|
162
|
+
// real variable: accepting it would silently disable the user's
|
|
163
|
+
// configuration AND hide any ambient name inside it from the denylist
|
|
164
|
+
// below. The usual cause is typing commas, as `KANKAKU_SUBAGENT_TOOLS`
|
|
165
|
+
// takes, where this list is `;`-separated.
|
|
166
|
+
if (!VALID_MARKER_NAME.test(name)) {
|
|
167
|
+
return `"${name}" is not a valid environment variable name — separate several markers with ";" (not ","), each as NAME or NAME=VALUE`;
|
|
168
|
+
}
|
|
169
|
+
const upper = name.toUpperCase();
|
|
170
|
+
if (DENIED_MARKER_NAMES.has(upper)) {
|
|
171
|
+
return `${name} is an ambient variable pi or the shell sets on EVERY process, not a marker exclusive to a subagent's child`;
|
|
172
|
+
}
|
|
173
|
+
for (const prefix of DENIED_MARKER_PREFIXES) {
|
|
174
|
+
if (upper.startsWith(prefix)) {
|
|
175
|
+
return `${name} looks like a pi/npm/shell-namespaced environment variable (prefix "${prefix}"), not a marker a third-party subagent tool would set`;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* C2 (CRITICAL fix, item 1): validate every `KANKAKU_SUBAGENT_CHILD_ENV`
|
|
183
|
+
* marker against the denylist above, config-time. A rejected marker is
|
|
184
|
+
* never added to the `"configured"` profile — so it can never demote a
|
|
185
|
+
* user's own top-level session to `role: "subagent"` in the first place
|
|
186
|
+
* (layered with C2 items 2/3's runtime interactive guard in
|
|
187
|
+
* `config.ts#detectRole`, which still protects a marker this denylist does
|
|
188
|
+
* not happen to catch). `loadConfig` surfaces `rejected` via
|
|
189
|
+
* `KankakuConfig.rejectedSubagentChildEnvMarkers` for `/kankaku doctor` and
|
|
190
|
+
* a one-time `ctx.ui.notify`.
|
|
191
|
+
*/
|
|
192
|
+
export function validateSubagentChildEnvMarkers(markers: readonly ChildEnvMarker[]): {
|
|
193
|
+
accepted: ChildEnvMarker[];
|
|
194
|
+
rejected: RejectedChildEnvMarker[];
|
|
195
|
+
} {
|
|
196
|
+
const accepted: ChildEnvMarker[] = [];
|
|
197
|
+
const rejected: RejectedChildEnvMarker[] = [];
|
|
198
|
+
for (const marker of markers) {
|
|
199
|
+
const reason = deniedMarkerReason(marker.name);
|
|
200
|
+
if (reason) {
|
|
201
|
+
rejected.push({ name: marker.name, reason });
|
|
202
|
+
} else {
|
|
203
|
+
accepted.push(marker);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { accepted, rejected };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function loadConfig(env: NodeJS.ProcessEnv = process.env): KankakuConfig {
|
|
210
|
+
const dir = env["KANKAKU_DIR"]?.trim() || DEFAULT_DIR;
|
|
211
|
+
const interactiveToolsRaw = env["KANKAKU_INTERACTIVE_TOOLS"]?.trim();
|
|
212
|
+
const interactiveTools = interactiveToolsRaw
|
|
213
|
+
? interactiveToolsRaw
|
|
214
|
+
.split(",")
|
|
215
|
+
.map((tool) => tool.trim())
|
|
216
|
+
.filter((tool) => tool.length > 0)
|
|
217
|
+
: DEFAULT_INTERACTIVE_TOOLS;
|
|
218
|
+
|
|
219
|
+
const segmentsRaw = env["KANKAKU_SEGMENTS"]?.trim();
|
|
220
|
+
const segmentRules = segmentsRaw ? parseSegmentRules(segmentsRaw) : DEFAULT_SEGMENT_RULES;
|
|
221
|
+
|
|
222
|
+
const client = env["KANKAKU_CLIENT"]?.trim() || undefined;
|
|
223
|
+
|
|
224
|
+
const subagentToolsRaw = env["KANKAKU_SUBAGENT_TOOLS"]?.trim();
|
|
225
|
+
const configuredTools = subagentToolsRaw ? parseSubagentTools(subagentToolsRaw) : [];
|
|
226
|
+
const subagentChildEnvRaw = env["KANKAKU_SUBAGENT_CHILD_ENV"]?.trim();
|
|
227
|
+
const parsedMarkers = subagentChildEnvRaw ? parseSubagentChildEnv(subagentChildEnvRaw) : [];
|
|
228
|
+
// C2 (CRITICAL fix, item 1): a denylisted marker name is never handed to
|
|
229
|
+
// buildConfiguredProfile at all — it can never demote a session's role,
|
|
230
|
+
// and is instead surfaced via rejectedSubagentChildEnvMarkers below.
|
|
231
|
+
const { accepted: configuredMarkers, rejected: rejectedSubagentChildEnvMarkers } = validateSubagentChildEnvMarkers(parsedMarkers);
|
|
232
|
+
const configuredProfile = buildConfiguredProfile(configuredTools, configuredMarkers);
|
|
233
|
+
const subagentProfiles: SubagentProfile[] = [...BUILTIN_SUBAGENT_PROFILES, ...(configuredProfile ? [configuredProfile] : [])];
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
dir,
|
|
237
|
+
interactiveTools,
|
|
238
|
+
subagentProfiles,
|
|
239
|
+
segmentRules,
|
|
240
|
+
rejectedSubagentChildEnvMarkers,
|
|
241
|
+
...(client !== undefined ? { client } : {}),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export interface RoleDetection {
|
|
246
|
+
role: "orchestrator" | "subagent";
|
|
247
|
+
/**
|
|
248
|
+
* Set only when this process could not be positively proven top-level
|
|
249
|
+
* (ADR 0022's four-state classification, applied on top of the still-
|
|
250
|
+
* binary `role`): no recognised child-env-marker matched, but a live
|
|
251
|
+
* tracked ancestor process was found via the machine-wide process
|
|
252
|
+
* registry, AND this process is not itself an interactive session (see
|
|
253
|
+
* `isInteractive` below — F3). See `domain/task-view.ts`'s
|
|
254
|
+
* `roleConfidence` handling.
|
|
255
|
+
*/
|
|
256
|
+
roleConfidence?: "uncertain";
|
|
257
|
+
/**
|
|
258
|
+
* Set when `KANKAKU_ROLE=subagent` was present, with no confirmed child
|
|
259
|
+
* marker, but was ignored because this process looked interactive (see
|
|
260
|
+
* this function's precedence doc — R1). The caller (`extension.ts`) is
|
|
261
|
+
* expected to surface this once via `ctx.ui.notify` at `session_start`
|
|
262
|
+
* and report it in `/kankaku doctor`, so the contradiction is never
|
|
263
|
+
* silent.
|
|
264
|
+
*/
|
|
265
|
+
overrideIgnoredInteractive?: true;
|
|
266
|
+
/**
|
|
267
|
+
* C2 item 2/3: set when a USER-CONFIGURED child-env marker
|
|
268
|
+
* (`KANKAKU_SUBAGENT_CHILD_ENV`, the `configuredMarkers` 5th param below)
|
|
269
|
+
* matched, but was ignored for this process's `role` because it looked
|
|
270
|
+
* interactive — a configured marker, unlike a BUILT-IN one, never demotes
|
|
271
|
+
* an interactive session (see this function's precedence doc). The
|
|
272
|
+
* caller is expected to surface this once (mirroring
|
|
273
|
+
* `overrideIgnoredInteractive`) via `ctx.ui.notify` and `/kankaku
|
|
274
|
+
* doctor`, and to escalate the wording when this process also has no
|
|
275
|
+
* tracked ancestor at all — the strongest signal the marker is genuinely
|
|
276
|
+
* ambient (C2 item 3's self-check), not a real subagent mechanism.
|
|
277
|
+
*/
|
|
278
|
+
configuredMarkerIgnoredInteractive?: true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export type RoleOverride = "orchestrator" | "subagent";
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* `KANKAKU_ROLE`: an explicit escape hatch for a genuine session
|
|
285
|
+
* `detectRole` gets wrong (no reliable automatic signal exists for it),
|
|
286
|
+
* and for a legacy/JSONL record already written `uncertain`, which can
|
|
287
|
+
* never be rewritten after the fact (the log is append-only) but whose
|
|
288
|
+
* *next* run can be told the truth directly. It does **not** override
|
|
289
|
+
* every other signal unconditionally any more — see `detectRole`'s
|
|
290
|
+
* precedence doc (R1) for the confirmed-child-marker and interactive-
|
|
291
|
+
* session exceptions this now has. An unrecognised value (anything other
|
|
292
|
+
* than exactly `"orchestrator"` or `"subagent"`) is ignored, falling back
|
|
293
|
+
* to normal detection, rather than failing the process or guessing.
|
|
294
|
+
*/
|
|
295
|
+
export function readRoleOverride(env: NodeJS.ProcessEnv = process.env): RoleOverride | undefined {
|
|
296
|
+
const raw = env["KANKAKU_ROLE"]?.trim();
|
|
297
|
+
return raw === "orchestrator" || raw === "subagent" ? raw : undefined;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Remove `KANKAKU_ROLE` from `env` in place (R1, layer 2 — non-
|
|
302
|
+
* propagation). `KANKAKU_ROLE` decides only THIS process's role; a child
|
|
303
|
+
* this process spawns (a subagent runner, a tool shell) must never inherit
|
|
304
|
+
* it, since `process.env` is inherited by every OS child by default. Left
|
|
305
|
+
* unstripped, a user who once hit a false `uncertain` and exported
|
|
306
|
+
* `KANKAKU_ROLE=orchestrator` in a shell rc/tmux/CI environment would have
|
|
307
|
+
* every subsequent subagent see it too — layer 1's precedence fix
|
|
308
|
+
* (a confirmed child marker always wins) already neutralises that specific
|
|
309
|
+
* leak for a *recognised* subagent mechanism, but this strips it outright
|
|
310
|
+
* so it can never reach an unrecognised one, or reach an unrelated child
|
|
311
|
+
* process this one spawns for some other reason. Takes the env object as
|
|
312
|
+
* a parameter, rather than reaching for `process.env` itself, so this
|
|
313
|
+
* stays a pure function tests can exercise against a plain object without
|
|
314
|
+
* ever mutating the real environment — `extension.ts` is the one caller
|
|
315
|
+
* that passes the real `process.env`, right after reading the override.
|
|
316
|
+
*/
|
|
317
|
+
export function stripRoleOverride(env: NodeJS.ProcessEnv): void {
|
|
318
|
+
delete env["KANKAKU_ROLE"];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Classify this process's role, strictly in this order (R1 rewrote this
|
|
323
|
+
* precedence — a leaked `KANKAKU_ROLE` must never out-rank a *confirmed*
|
|
324
|
+
* signal, and must never silently drop a genuine interactive session):
|
|
325
|
+
*
|
|
326
|
+
* 1. `GENTLE_PI_AGENTS_CHILD=1` — the automatic confirmed-subagent marker,
|
|
327
|
+
* set only by the subagent runner itself, never something a shell
|
|
328
|
+
* rc/tmux/CI environment would export. **Always wins**, even over an
|
|
329
|
+
* explicit `KANKAKU_ROLE=orchestrator` — without this, a leaked
|
|
330
|
+
* `KANKAKU_ROLE=orchestrator` export would turn every one of this
|
|
331
|
+
* process's genuine subagent invocations into a confirmed,
|
|
332
|
+
* independently-billed orchestrator too (the bug this fixes).
|
|
333
|
+
* 2. `KANKAKU_ROLE` (F3's explicit escape hatch), when set to a recognised
|
|
334
|
+
* value and no confirmed marker matched above — with one exception:
|
|
335
|
+
* `KANKAKU_ROLE=subagent` in an **interactive** session (`isInteractive`
|
|
336
|
+
* — see below) is ignored. No subagent mechanism kankaku recognises
|
|
337
|
+
* ever launches its child interactively; an interactive session with
|
|
338
|
+
* this override set and no marker to back it up is therefore almost
|
|
339
|
+
* certainly a leaked shell export, not a real subagent. Honouring it
|
|
340
|
+
* would silently drop this session's own work from every report and
|
|
341
|
+
* the hub (an orphaned subagent record that never anchors a task) with
|
|
342
|
+
* no way to recover it later, since `worklog.jsonl` is append-only.
|
|
343
|
+
* Between the package's two guiding rules — "undercount is recoverable,
|
|
344
|
+
* overcount is not" (which governs the *opposite* risk, inventing extra
|
|
345
|
+
* billing, and does not apply here) and "never silently drop genuine
|
|
346
|
+
* work" — this is governed by the second: the override is ignored, this
|
|
347
|
+
* process is classified `orchestrator` (what it structurally must be),
|
|
348
|
+
* and `overrideIgnoredInteractive` is set so the caller can surface the
|
|
349
|
+
* contradiction instead of resolving it silently. A non-interactive
|
|
350
|
+
* process gets exactly what it asked for. `KANKAKU_ROLE=orchestrator`
|
|
351
|
+
* has no such exception — forcing a session `orchestrator` can never
|
|
352
|
+
* drop work, only (rarely) invent a task that should not exist, a risk
|
|
353
|
+
* the user accepted by setting it explicitly.
|
|
354
|
+
* 3. `hasTrackedAncestor` — whether this process's own OS ancestor chain
|
|
355
|
+
* contains a live, identity-verified entry in the machine-wide process
|
|
356
|
+
* registry (computed by the caller, e.g. `adapters/subagent-startup.ts`,
|
|
357
|
+
* via `adapters/ancestry.ts` + `domain/ancestry-match.ts`; see
|
|
358
|
+
* `ports/process-registry.ts`) — combined with `isInteractive`: only a
|
|
359
|
+
* *non-interactive* process with a tracked ancestor is demoted to
|
|
360
|
+
* `uncertain` (ADR 0022's safe default, inverted). An interactive TUI
|
|
361
|
+
* session on a real terminal is a human's own session even when some
|
|
362
|
+
* ancestor happens to be a tracked pi process (e.g. pi launched from
|
|
363
|
+
* inside another pi's shell tool) — every subagent mechanism kankaku
|
|
364
|
+
* recognises launches its child non-interactively over pipes, so
|
|
365
|
+
* `isInteractive` alone already tells a genuine top-level session apart
|
|
366
|
+
* from one that could plausibly be someone's silent child.
|
|
367
|
+
*
|
|
368
|
+
* `isInteractive` defaults to `true` — the same conservative default used
|
|
369
|
+
* for both the interactive-override exception above and the `uncertain`
|
|
370
|
+
* fallback below, since a caller that does not yet know it (see
|
|
371
|
+
* `extension.ts`'s factory-time synchronous TTY proxy, and F3's later,
|
|
372
|
+
* authoritative `ctx.mode === "tui"` refinement of `roleConfidence` only —
|
|
373
|
+
* never of `role` itself) should never wrongly honour a `subagent` override
|
|
374
|
+
* or demote a session to `uncertain` before it can find out.
|
|
375
|
+
*
|
|
376
|
+
* A process that cannot be shown to be top-level by any of the above must
|
|
377
|
+
* never default to `"orchestrator"` outright — but see F2: when ancestor
|
|
378
|
+
* detection itself is unavailable (platform, or a failed/timed-out probe),
|
|
379
|
+
* `hasTrackedAncestor` is simply `false` (nothing was found), which already
|
|
380
|
+
* falls through to a confirmed orchestrator here — the *old*, pre-ADR-0022
|
|
381
|
+
* behaviour on such a platform, deliberately: marking every unprovable
|
|
382
|
+
* process `uncertain` there would drop all of a Windows user's genuine
|
|
383
|
+
* work, a far worse failure than the narrow overcount this guards against
|
|
384
|
+
* elsewhere. `/kankaku doctor` is responsible for making that unavailable-
|
|
385
|
+
* detection limitation visible; it is never encoded in `roleConfidence`.
|
|
386
|
+
*
|
|
387
|
+
* C2 (CRITICAL fix): `childMarkers` (built-in tier, step 1 above) and
|
|
388
|
+
* `configuredMarkers` (the 5th param) are now two DIFFERENT tiers, not one
|
|
389
|
+
* merged list. A BUILT-IN marker (`GENTLE_PI_AGENTS_CHILD`,
|
|
390
|
+
* `PI_SUBAGENT_DEPTH`) is set only by a real subagent runner and always
|
|
391
|
+
* wins outright, exactly as step 1 describes — verified that no built-in
|
|
392
|
+
* mechanism kankaku recognises ever launches its child interactively (see
|
|
393
|
+
* `domain/subagent-profile.ts#PI_SUBAGENTS_PROFILE`'s doc comment), so this
|
|
394
|
+
* tier never actually needs the interactive exception in practice, and
|
|
395
|
+
* keeping it unconditional avoids a behaviour change for it. A
|
|
396
|
+
* USER-CONFIGURED marker (`KANKAKU_SUBAGENT_CHILD_ENV`), by contrast, names
|
|
397
|
+
* an arbitrary environment variable kankaku cannot verify is child-only —
|
|
398
|
+
* pi itself sets `PI_CODING_AGENT`/`AI_AGENT` on EVERY process, and a naive
|
|
399
|
+
* choice like that (or `CI`, `TMUX`, an exported shell var) would make the
|
|
400
|
+
* user's own top-level interactive session `role: "subagent"` with no
|
|
401
|
+
* parent, never anchoring a task and unrecoverable once written (the log
|
|
402
|
+
* is append-only). So a configured marker gets EXACTLY the same
|
|
403
|
+
* interactive exception `KANKAKU_ROLE=subagent` already has (step 2): it
|
|
404
|
+
* never demotes an interactive session — `configuredMarkerIgnoredInteractive`
|
|
405
|
+
* is set instead, so the caller can self-check and surface the
|
|
406
|
+
* contradiction (C2 item 3) rather than silently trusting an ambient
|
|
407
|
+
* marker. See `config.ts#loadConfig`'s denylist for the config-time half of
|
|
408
|
+
* this fix (rejecting an obviously-ambient marker name outright).
|
|
409
|
+
*/
|
|
410
|
+
/** `detectRole`'s default `childMarkers` when a caller does not pass its own active profile set — identical to the single marker this function hardcoded before SUBAGENT-REQ-001/002/003, so every pre-existing 3-arg call site keeps behaving exactly as before. */
|
|
411
|
+
const DEFAULT_CHILD_MARKERS: ChildEnvMarker[] = [{ name: "GENTLE_PI_AGENTS_CHILD", value: "1" }];
|
|
412
|
+
|
|
413
|
+
export function detectRole(
|
|
414
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
415
|
+
hasTrackedAncestor = false,
|
|
416
|
+
isInteractive = true,
|
|
417
|
+
/** SUBAGENT-REQ-002/003: built-in profiles' own child-env markers ONLY (never the user-configured one — see `configuredMarkers` below) — generalises the single hardcoded `GENTLE_PI_AGENTS_CHILD` check without changing precedence. See `domain/subagent-profile.ts#builtinChildMarkers`. */
|
|
418
|
+
childMarkers: readonly ChildEnvMarker[] = DEFAULT_CHILD_MARKERS,
|
|
419
|
+
/** C2: the user-configured profile's child-env marker(s) only (`KANKAKU_SUBAGENT_CHILD_ENV`, already denylist-filtered by `loadConfig`) — a SEPARATE, weaker tier: it can confirm a subagent, but unlike `childMarkers` above, never demotes an interactive session. See `domain/subagent-profile.ts#configuredChildMarkers`. */
|
|
420
|
+
configuredMarkers: readonly ChildEnvMarker[] = [],
|
|
421
|
+
): RoleDetection {
|
|
422
|
+
if (matchesAnyMarker(env, childMarkers)) return { role: "subagent" };
|
|
423
|
+
|
|
424
|
+
const configuredMatch = matchesAnyMarker(env, configuredMarkers);
|
|
425
|
+
if (configuredMatch && !isInteractive) return { role: "subagent" };
|
|
426
|
+
|
|
427
|
+
let result: RoleDetection;
|
|
428
|
+
const override = readRoleOverride(env);
|
|
429
|
+
|
|
430
|
+
if (override === "orchestrator") {
|
|
431
|
+
result = { role: "orchestrator" };
|
|
432
|
+
} else if (override === "subagent") {
|
|
433
|
+
result = isInteractive ? { role: "orchestrator", overrideIgnoredInteractive: true } : { role: "subagent" };
|
|
434
|
+
} else if (hasTrackedAncestor && !isInteractive) {
|
|
435
|
+
result = { role: "orchestrator", roleConfidence: "uncertain" };
|
|
436
|
+
} else {
|
|
437
|
+
result = { role: "orchestrator" };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// The configured marker matched but was ignored (this process is
|
|
441
|
+
// interactive) — flagged regardless of what else decided `result`, so the
|
|
442
|
+
// caller always learns a configured marker is present-but-ambient here.
|
|
443
|
+
return configuredMatch && isInteractive ? { ...result, configuredMarkerIgnoredInteractive: true } : result;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Hub (PocketBase) credentials read from the environment; any field can be absent. */
|
|
447
|
+
export interface HubEnvCredentials {
|
|
448
|
+
url?: string;
|
|
449
|
+
email?: string;
|
|
450
|
+
password?: string;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Read `KANKAKU_PB_URL`/`KANKAKU_PB_EMAIL`/`KANKAKU_PB_PASSWORD`. Empty/whitespace-only values are treated as absent. */
|
|
454
|
+
export function loadHubEnvCredentials(env: NodeJS.ProcessEnv = process.env): HubEnvCredentials {
|
|
455
|
+
return {
|
|
456
|
+
url: env["KANKAKU_PB_URL"]?.trim() || undefined,
|
|
457
|
+
email: env["KANKAKU_PB_EMAIL"]?.trim() || undefined,
|
|
458
|
+
password: env["KANKAKU_PB_PASSWORD"]?.trim() || undefined,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** `KANKAKU_MACHINE`, or `hostname()` when unset/blank. Injected so this stays testable without touching `os.hostname`. */
|
|
463
|
+
export function loadMachine(env: NodeJS.ProcessEnv, hostname: () => string): string {
|
|
464
|
+
return env["KANKAKU_MACHINE"]?.trim() || hostname();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Hosts allowed to use a plain-HTTP hub URL. */
|
|
468
|
+
function isLocalHost(hostname: string): boolean {
|
|
469
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Hub sync (Phase 2) configuration, read from the environment. See README "Hub (PocketBase)" sync section. */
|
|
473
|
+
export interface SyncConfig {
|
|
474
|
+
/** `KANKAKU_SYNC_PROMPT`. Defaults to `"none"` — the conservative default (proposal §8). */
|
|
475
|
+
promptMode: PromptPrivacyMode;
|
|
476
|
+
/** `KANKAKU_SYNC_WINDOW_HOURS`. Defaults to 24; falls back to the default for a non-positive or non-numeric value. */
|
|
477
|
+
windowHours: number;
|
|
478
|
+
/** `KANKAKU_SYNC_RECORDS`. Defaults to enabled; `"0"` disables uploading `work_records` children. */
|
|
479
|
+
syncRecords: boolean;
|
|
480
|
+
/** `KANKAKU_SYNC_AUTO`. Defaults to enabled; `"0"` disables every automatic sync: the fire-and-forget session_start/agent_settled ones and the awaited session_shutdown one. */
|
|
481
|
+
auto: boolean;
|
|
482
|
+
/**
|
|
483
|
+
* `KANKAKU_SYNC_MIN_INTERVAL_MINUTES`. How often the *automatic*
|
|
484
|
+
* (`session_start`/`agent_settled`) sync path is allowed to actually run
|
|
485
|
+
* a sync, at most. Defaults to 5; `0` disables throttling entirely. Never
|
|
486
|
+
* applies to a manual `/kankaku sync`, `sync all`, or `backfill`. See
|
|
487
|
+
* `adapters/sync-runner.ts#runSync`.
|
|
488
|
+
*/
|
|
489
|
+
minIntervalMinutes: number;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const VALID_PROMPT_MODES = new Set<PromptPrivacyMode>(["none", "truncated", "full"]);
|
|
493
|
+
const DEFAULT_SYNC_WINDOW_HOURS = 24;
|
|
494
|
+
const DEFAULT_SYNC_MIN_INTERVAL_MINUTES = 5;
|
|
495
|
+
|
|
496
|
+
export function loadSyncConfig(env: NodeJS.ProcessEnv = process.env): SyncConfig {
|
|
497
|
+
const promptRaw = env["KANKAKU_SYNC_PROMPT"]?.trim();
|
|
498
|
+
const promptMode: PromptPrivacyMode = promptRaw && VALID_PROMPT_MODES.has(promptRaw as PromptPrivacyMode) ? (promptRaw as PromptPrivacyMode) : "none";
|
|
499
|
+
|
|
500
|
+
const windowRaw = env["KANKAKU_SYNC_WINDOW_HOURS"]?.trim();
|
|
501
|
+
const parsedWindow = windowRaw ? Number(windowRaw) : NaN;
|
|
502
|
+
const windowHours = Number.isFinite(parsedWindow) && parsedWindow > 0 ? parsedWindow : DEFAULT_SYNC_WINDOW_HOURS;
|
|
503
|
+
|
|
504
|
+
const syncRecords = env["KANKAKU_SYNC_RECORDS"]?.trim() !== "0";
|
|
505
|
+
const auto = env["KANKAKU_SYNC_AUTO"]?.trim() !== "0";
|
|
506
|
+
|
|
507
|
+
// 0 is a valid, explicit "disable throttling" value, distinct from an
|
|
508
|
+
// unset or garbage one (which falls back to the default) — unlike
|
|
509
|
+
// windowHours above, which treats 0 as invalid.
|
|
510
|
+
const minIntervalRaw = env["KANKAKU_SYNC_MIN_INTERVAL_MINUTES"]?.trim();
|
|
511
|
+
const parsedMinInterval = minIntervalRaw ? Number(minIntervalRaw) : NaN;
|
|
512
|
+
const minIntervalMinutes = Number.isFinite(parsedMinInterval) && parsedMinInterval >= 0 ? parsedMinInterval : DEFAULT_SYNC_MIN_INTERVAL_MINUTES;
|
|
513
|
+
|
|
514
|
+
return { promptMode, windowHours, syncRecords, auto, minIntervalMinutes };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export type HubUrlValidation = { ok: true } | { ok: false; reason: string };
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* A hub URL must be HTTPS, unless it points at localhost/127.0.0.1/::1 (a
|
|
521
|
+
* local PocketBase instance for development). Also rejects a URL that does
|
|
522
|
+
* not parse at all.
|
|
523
|
+
*/
|
|
524
|
+
export function validateHubUrl(url: string): HubUrlValidation {
|
|
525
|
+
let parsed: URL;
|
|
526
|
+
try {
|
|
527
|
+
parsed = new URL(url);
|
|
528
|
+
} catch {
|
|
529
|
+
return { ok: false, reason: `kankaku: invalid hub URL: ${url}` };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (parsed.protocol === "https:") return { ok: true };
|
|
533
|
+
if (parsed.protocol === "http:" && isLocalHost(parsed.hostname)) return { ok: true };
|
|
534
|
+
|
|
535
|
+
return { ok: false, reason: `kankaku: refusing non-HTTPS hub URL (only localhost is allowed over plain HTTP): ${url}` };
|
|
536
|
+
}
|