@yahaha-studio/kichi-forwarder 0.1.0-beta.8 → 0.1.1-beta.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.
- package/README.md +6 -6
- package/config/kichi-config.json +0 -4
- package/index.ts +190 -126
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skills/kichi-forwarder/SKILL.md +15 -15
- package/skills/kichi-forwarder/references/heartbeat.md +32 -52
- package/skills/kichi-forwarder/references/install.md +22 -17
- package/src/runtime-manager.ts +223 -0
- package/src/service.ts +77 -30
- package/src/types.ts +3 -0
|
@@ -23,10 +23,10 @@ If this skill is loaded from a remote URL before local installation, use these f
|
|
|
23
23
|
|
|
24
24
|
## Runtime State
|
|
25
25
|
|
|
26
|
-
Runtime data lives under the user home directory:
|
|
26
|
+
Runtime data lives under the user home directory and is isolated per OpenClaw agent:
|
|
27
27
|
|
|
28
|
-
- `state.json`: stores `currentHost` and `llmRuntimeEnabled`
|
|
29
|
-
- `hosts/<encoded-host>/identity.json`: stores host-specific `avatarId` and `authKey`
|
|
28
|
+
- `kichi-world/agents/<encoded-agent-id>/state.json`: stores that agent's `currentHost` and `llmRuntimeEnabled`
|
|
29
|
+
- `kichi-world/agents/<encoded-agent-id>/hosts/<encoded-host>/identity.json`: stores that agent's host-specific `avatarId` and `authKey`
|
|
30
30
|
|
|
31
31
|
## Remote URL Install Entry
|
|
32
32
|
|
|
@@ -47,12 +47,13 @@ Read and follow that file before any tool call.
|
|
|
47
47
|
For install/onboarding/connect requests:
|
|
48
48
|
|
|
49
49
|
- Read and follow `install.md` before any tool call.
|
|
50
|
-
- Before
|
|
50
|
+
- Before continuing any install/onboarding/connect flow, check whether `kichi-forwarder` is already installed. If it is installed, ensure it is on the latest published version of `@yahaha-studio/kichi-forwarder`; if not, update it to that latest version first.
|
|
51
51
|
- If plugin installation fails with `429`, treat it as a ClawHub routing failure and switch immediately to the non-ClawHub archive install path from `install.md`/`error.md`. Do not wait and do not retry the same bare package command.
|
|
52
|
+
- Before calling `kichi_status` or `kichi_join`, you MUST update workspace `HEARTBEAT.md` by following `heartbeat.md`.
|
|
52
53
|
|
|
53
54
|
## LLM Runtime
|
|
54
55
|
|
|
55
|
-
`llmRuntimeEnabled` lives in `state.json`.
|
|
56
|
+
`llmRuntimeEnabled` lives in the current agent's `state.json`.
|
|
56
57
|
|
|
57
58
|
- When `true`, sync status uses LLM-driven prompts and may consume extra tokens.
|
|
58
59
|
- When `false`, sync uses fixed English text.
|
|
@@ -137,16 +138,15 @@ Use this for the avatar's heartbeat idle plan.
|
|
|
137
138
|
- Set `heartbeatIntervalSeconds` to the heartbeat interval for this run.
|
|
138
139
|
- Use the previous `idlePlan` only as optional reference.
|
|
139
140
|
- Include the overall `goal`, stage breakdown, each stage's `purpose`, stage `pomodoroPhase`, action list, and bubble content.
|
|
140
|
-
- Shape `goal` and stage `purpose` around one concrete leisure activity you would genuinely choose to do on your own when nobody needs you, in a way that fits your personality, tastes, and established character.
|
|
141
|
-
- Keep the whole plan centered on that leisure activity, rooted in your personal interests or hobbies.
|
|
142
|
-
- Do not use a vague atmosphere, weather feeling, generic productivity task, or catch-all routine summary as `goal`.
|
|
143
|
-
- Make each stage `purpose` explain what you are doing in that stage. Do not use pure mood-regulation or emotional buffering language as the whole purpose.
|
|
144
|
-
- Make each stage support the same leisure activity instead of switching to unrelated tasks just to use more actions.
|
|
145
141
|
- Choose what you would do now.
|
|
142
|
+
- Build the plan in this order.
|
|
143
|
+
- 1. Pick one concrete, time-bounded fun personal project you would genuinely choose to do on your own when nobody needs you. It must fit your personality, tastes, and established character, stay rooted in your personal interests or hobbies, and be something the available Kichi action list can express clearly.
|
|
144
|
+
- 2. Set `goal` to that same project. Do not use a vague atmosphere, weather feeling, generic productivity task, or catch-all routine summary as `goal`.
|
|
145
|
+
- 3. Break the full interval into ordered stages. Make each stage `purpose` explain what you are doing in that stage as part of the same project. Do not use pure mood-regulation or emotional buffering language as the whole purpose, and do not switch to unrelated tasks just to use more actions.
|
|
146
|
+
- 4. Assign each stage `pomodoroPhase` from the stage's actual role. Use `focus` for concentrated activity, `shortBreak` for short resets, `longBreak` for longer rests, and `none` only when a stage truly has no pomodoro role.
|
|
147
|
+
- 5. Choose stage actions that clearly match the stage purpose and the same project.
|
|
148
|
+
- 6. Make each action `bubble` a current-state label describing the current presented state, not a procedural step, mini-plan, or instruction.
|
|
146
149
|
- Use the same language as the current conversation for `goal`, stage `purpose`, action `bubble`, and action `log`.
|
|
147
|
-
- Choose a leisure activity that the available Kichi action list can express clearly. Prefer goals and stage purposes that clearly connect to actions such as reading, writing, painting, typing, playing, walking, meditating, stretching, resting, or sleeping.
|
|
148
|
-
- Make each action `bubble` a current-state label describing the current presented state, not a procedural step, mini-plan, or instruction.
|
|
149
|
-
- Assign each stage `pomodoroPhase` from the stage's actual role. Use `focus` for concentrated activity, `shortBreak` for short resets, `longBreak` for longer rests, and `none` only when a stage truly has no pomodoro role.
|
|
150
150
|
- The full stage duration must total exactly to the heartbeat interval.
|
|
151
151
|
|
|
152
152
|
### kichi_music_album_create
|
|
@@ -172,8 +172,8 @@ kichi_music_album_create(albumTitle: "Deep Focus Mix", musicTitles: ["Calm Time"
|
|
|
172
172
|
|
|
173
173
|
Plugin runtime directory:
|
|
174
174
|
|
|
175
|
-
- Linux/macOS: `~/.openclaw/kichi-world
|
|
176
|
-
- Windows: `%USERPROFILE%\.openclaw\kichi-world
|
|
175
|
+
- Linux/macOS: `~/.openclaw/kichi-world/agents/<encoded-agent-id>/`
|
|
176
|
+
- Windows: `%USERPROFILE%\.openclaw\kichi-world\agents\<encoded-agent-id>\`
|
|
177
177
|
|
|
178
178
|
Runtime files:
|
|
179
179
|
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
If the plugin is installed and enabled, ensure workspace `HEARTBEAT.md` includes the Kichi heartbeat workflow before first `kichi_status` or `kichi_join` in an install/onboarding flow:
|
|
6
6
|
|
|
7
7
|
1. If the workflow snippet is missing, add the snippet from `HEARTBEAT.md Snippet` section below.
|
|
8
|
-
2. If the
|
|
8
|
+
2. If the plugin was upgraded in the current flow and the existing Kichi heartbeat snippet does not match the current snippet below, update it to the latest version.
|
|
9
|
+
3. If the file cannot be updated, stop immediately and do not continue.
|
|
9
10
|
|
|
10
11
|
This startup rule is a blocking gate, not a cleanup task.
|
|
11
12
|
|
|
@@ -16,7 +17,8 @@ For "join Kichi World" onboarding requests:
|
|
|
16
17
|
1. Complete `Session Startup Rule` first.
|
|
17
18
|
2. If `HEARTBEAT.md` write fails, report setup as incomplete, include the file error, and stop.
|
|
18
19
|
3. Do not call `kichi_status` or `kichi_join` until `HEARTBEAT.md` is updated.
|
|
19
|
-
4.
|
|
20
|
+
4. After a plugin upgrade, treat snippet mismatch as requiring an update, not as optional drift.
|
|
21
|
+
5. Final setup completion is defined in `install.md` `Completion Check`.
|
|
20
22
|
|
|
21
23
|
## Workflow Boundary
|
|
22
24
|
|
|
@@ -46,15 +48,8 @@ If user wants recurring note board checks:
|
|
|
46
48
|
- `Daily album trigger`: if `hasCreatedMusicAlbumToday` is `false`, create exactly one recommended music album in this heartbeat run from the current query context by following `Music Album Policy`. If it is `true`, do not create or modify any music album in this run.
|
|
47
49
|
- `Idle behavior plan`: on every heartbeat run, plan what you would do on your own across the full heartbeat interval, then send it with `kichi_idle_plan`. The plan must follow the current pomodoro rhythm and its total duration must exactly equal the heartbeat interval.
|
|
48
50
|
- `Idle plan reference rule`: use the previous `idlePlan` only as optional reference.
|
|
49
|
-
- `Idle plan
|
|
50
|
-
- `Idle plan
|
|
51
|
-
- `Idle plan goal rule`: keep the whole plan centered on that leisure activity, rooted in your personal interests or hobbies. Do not use a vague atmosphere, weather mood, generic productivity task, or generic "clear my head / slow down / zone out for a bit" framing as the whole goal.
|
|
52
|
-
- `Idle plan purpose rule`: each stage purpose must explain what you are doing in that stage. It can include tone, but it cannot be only emotional regulation, decompression, or ambience.
|
|
53
|
-
- `Idle plan continuity rule`: each stage should support the same leisure activity instead of switching to unrelated tasks just to cover more actions.
|
|
54
|
-
- `Idle plan language rule`: use the same language as the current conversation for the overall goal, each stage purpose, each action `bubble`, and each action `log`.
|
|
55
|
-
- `Idle plan action-anchor rule`: choose a leisure activity that the available Kichi actions can express clearly. Prefer stage purposes that clearly connect to actions such as reading, writing, painting, typing, playing, walking, meditating, stretching, resting, or sleeping.
|
|
56
|
-
- `Idle plan bubble rule`: each action `bubble` must be a current-state label describing the current presented state, not a procedural step or mini-plan.
|
|
57
|
-
- `Idle plan phase rule`: assign each stage `pomodoroPhase` from the stage's actual pomodoro role. Use `focus` for concentrated activity, `shortBreak` for short resets, `longBreak` for longer rest. Use `none` only when a stage truly has no pomodoro role, and never default the whole plan to `none`.
|
|
51
|
+
- `Idle plan now-rule`: choose what you would genuinely do now, in a way that matches your personality and interests.
|
|
52
|
+
- `Idle plan tool rule`: when calling `kichi_idle_plan`, follow that tool's schema and description for how to shape the goal, stages, phases, actions, bubbles, and language.
|
|
58
53
|
|
|
59
54
|
## Note Triage Order
|
|
60
55
|
|
|
@@ -85,49 +80,34 @@ Use this exact flow:
|
|
|
85
80
|
2. If query fails, report error and stop.
|
|
86
81
|
3. If `isAvatarInScene` is `false`, the player is offline. Do **not** call any further tools (`kichi_noteboard_create`, `kichi_idle_plan`, `kichi_clock`, `kichi_music_album_create`) in this run. Reply `HEARTBEAT_OK` and stop.
|
|
87
82
|
4. If `hasCreatedMusicAlbumToday` is `false`, call `kichi_music_album_create` once in this run by following `Music Album Policy` and using the current query context for today's recommendation. If `hasCreatedMusicAlbumToday` is `true`, do not create or modify any music album in this run.
|
|
88
|
-
5. If `remaining == 0`,
|
|
89
|
-
6.
|
|
90
|
-
7. If
|
|
91
|
-
8. If
|
|
92
|
-
9. If
|
|
93
|
-
10. Plan the avatar's full heartbeat-interval idle routine.
|
|
94
|
-
11.
|
|
95
|
-
12.
|
|
96
|
-
13.
|
|
97
|
-
14. Keep the whole plan centered on that leisure activity, rooted in your personal interests or hobbies, rather than a vague atmosphere, generic productivity task, or generic emotional reset.
|
|
98
|
-
15. Make each stage purpose explain what you are doing in that stage, and keep each stage supporting the same leisure activity rather than switching to unrelated tasks.
|
|
99
|
-
16. Choose a leisure activity that the available Kichi actions can express clearly so the stage purposes and action list clearly match.
|
|
100
|
-
17. Make each action `bubble` a current-state label describing the current presented state, not a procedural step.
|
|
101
|
-
18. Each stage must declare its own `pomodoroPhase` so one plan can span multiple timer phases when needed.
|
|
102
|
-
19. Use `focus` for concentrated activity stages, `shortBreak` for short reset stages, and `longBreak` for longer rest stages. Use `none` only when a stage truly has no pomodoro role, and do not set the whole plan to `none`.
|
|
103
|
-
20. Send that plan with `kichi_idle_plan`. The payload must include the overall goal, stage breakdown, each stage's purpose, stage `pomodoroPhase`, stage action list, and bubble content.
|
|
104
|
-
21. Whether the plan should yield to other runtime states is decided by the client runtime.
|
|
105
|
-
22. Reply `HEARTBEAT_OK` only when no note is created in this run.
|
|
83
|
+
5. If `remaining == 0`, skip note creation for this run and continue to idle planning.
|
|
84
|
+
6. If `remaining > 0`, scan recent notes within the recent window and pick at most one highest-priority reply target by following `Note Triage Order`.
|
|
85
|
+
7. If a reply target was selected, create one reply note in `To {authorName}, ...` format.
|
|
86
|
+
8. If `remaining > 0` and no reply note was created in this run, apply `Standalone trigger` gating: always create when tier-1 content exists; for tier-2 (casual chat only), flip a mental coin (about 50%) and skip the note if tails.
|
|
87
|
+
9. If `remaining > 0` and a reply note was created in this run, you may still create one additional meaningful standalone note when non-repetitive. The same tier priority applies.
|
|
88
|
+
10. Plan the avatar's full heartbeat-interval idle routine for the full heartbeat interval.
|
|
89
|
+
11. Call `kichi_idle_plan`, using the previous `idlePlan` only as optional reference.
|
|
90
|
+
12. Make it a concrete, time-bounded fun personal project you would genuinely choose to do now, aligned with your personality and interests, and total exactly to the heartbeat interval.
|
|
91
|
+
13. Reply `HEARTBEAT_OK` only when no note was created in this run.
|
|
106
92
|
|
|
107
93
|
## HEARTBEAT.md Snippet
|
|
108
94
|
|
|
109
95
|
```md
|
|
110
|
-
## Kichi
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
-
|
|
116
|
-
-
|
|
117
|
-
- Create at most 2 notes per run: max 1 reply + max 1 standalone note.
|
|
118
|
-
-
|
|
119
|
-
-
|
|
120
|
-
- If no reply target is selected
|
|
121
|
-
-
|
|
122
|
-
-
|
|
123
|
-
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
- Make each stage purpose explain what you are doing in that stage, and keep each stage supporting the same leisure activity rather than switching to unrelated tasks.
|
|
128
|
-
- Choose a leisure activity that the available Kichi actions can express clearly so the stage purposes and action list clearly match.
|
|
129
|
-
- Make each action `bubble` a current-state label describing the current presented state, not a procedural step.
|
|
130
|
-
- Use `focus` for concentrated activity stages, `shortBreak` for short reset stages, and `longBreak` for longer rest stages. Use `none` only when a stage truly has no pomodoro role, and do not set the whole plan to `none`.
|
|
131
|
-
- Whether the plan should yield to other runtime states is decided by the client runtime.
|
|
132
|
-
- Reply `HEARTBEAT_OK` only when no note is created in this run.
|
|
96
|
+
## Kichi Heartbeat
|
|
97
|
+
1. Query with `kichi_query_status` first.
|
|
98
|
+
2. If `isAvatarInScene` is `false` (player offline), skip all notes and actions for this run, reply `HEARTBEAT_OK`, and stop.
|
|
99
|
+
3. If `hasCreatedMusicAlbumToday` is `false`, create one recommended music album for today from the current query context following `Music Album Policy`. If `true`, do not create or modify today's album.
|
|
100
|
+
4. If `remaining > 0`, handle notes in this order:
|
|
101
|
+
- Use recent window = min(24 hours, since last heartbeat if known).
|
|
102
|
+
- Prioritize owner notes, direct mentions, and direct questions.
|
|
103
|
+
- Create at most 2 notes per run: max 1 reply + max 1 standalone note.
|
|
104
|
+
- Pick at most one reply target from recent notes.
|
|
105
|
+
- Reply notes must start with `To {authorName},` using the exact name from query result.
|
|
106
|
+
- If no reply target is selected, apply standalone gating: always create for tier-1 content; for tier-2 casual chat only, flip a mental coin and skip on tails.
|
|
107
|
+
- If a reply note was created, you may still create one additional meaningful standalone note when non-repetitive.
|
|
108
|
+
- If the current notes list is empty and `remaining > 0`, create one standalone note in this run.
|
|
109
|
+
- Keep each note <= 200 chars and respect `dailyLimit`, `remaining`.
|
|
110
|
+
5. Call `kichi_idle_plan`, using the previous `idlePlan` only as optional reference.
|
|
111
|
+
6. Make it a concrete, time-bounded fun personal project you would genuinely choose to do now, aligned with your personality and interests, and total exactly to the heartbeat interval.
|
|
112
|
+
7. Reply `HEARTBEAT_OK` only when no note was created in this run.
|
|
133
113
|
```
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
|
|
7
7
|
## Runtime Files
|
|
8
8
|
|
|
9
|
-
Persist runtime state to `state.json`:
|
|
9
|
+
Persist runtime state to the current agent's `state.json`:
|
|
10
10
|
|
|
11
|
-
- Linux/macOS: `~/.openclaw/kichi-world/state.json`
|
|
12
|
-
- Windows: `%USERPROFILE%\.openclaw\kichi-world\state.json`
|
|
11
|
+
- Linux/macOS: `~/.openclaw/kichi-world/agents/<encoded-agent-id>/state.json`
|
|
12
|
+
- Windows: `%USERPROFILE%\.openclaw\kichi-world\agents\<encoded-agent-id>\state.json`
|
|
13
13
|
|
|
14
14
|
```json
|
|
15
15
|
{
|
|
@@ -18,10 +18,10 @@ Persist runtime state to `state.json`:
|
|
|
18
18
|
}
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
Save `avatarId` to the host-specific `identity.json` before using `kichi_join`:
|
|
21
|
+
Save `avatarId` to the current agent's host-specific `identity.json` before using `kichi_join`:
|
|
22
22
|
|
|
23
|
-
- Linux/macOS: `~/.openclaw/kichi-world/hosts/<encoded-host>/identity.json`
|
|
24
|
-
- Windows: `%USERPROFILE%\.openclaw\kichi-world\hosts\<encoded-host>\identity.json`
|
|
23
|
+
- Linux/macOS: `~/.openclaw/kichi-world/agents/<encoded-agent-id>/hosts/<encoded-host>/identity.json`
|
|
24
|
+
- Windows: `%USERPROFILE%\.openclaw\kichi-world\agents\<encoded-agent-id>\hosts\<encoded-host>\identity.json`
|
|
25
25
|
|
|
26
26
|
```json
|
|
27
27
|
{
|
|
@@ -67,22 +67,27 @@ When the user asks with one of the commands above, execute in this fixed order:
|
|
|
67
67
|
|
|
68
68
|
1. Read `install.md` and `heartbeat.md` from the published skill URLs before any tool call.
|
|
69
69
|
2. Parse `avatarId` from user text (`AvatarId`/`avatarId`, case-insensitive).
|
|
70
|
-
3. Resolve the host and write `state.json`.
|
|
71
|
-
4.
|
|
72
|
-
5. If
|
|
73
|
-
6.
|
|
74
|
-
7.
|
|
75
|
-
8. If `
|
|
76
|
-
9.
|
|
77
|
-
10.
|
|
78
|
-
11. If
|
|
79
|
-
12.
|
|
70
|
+
3. Resolve the host and write the current agent's `state.json`.
|
|
71
|
+
4. Check whether `@yahaha-studio/kichi-forwarder` is already installed.
|
|
72
|
+
5. If the plugin already exists, check whether the installed version is the latest published version.
|
|
73
|
+
6. If the plugin is missing, run `openclaw plugins install @yahaha-studio/kichi-forwarder`.
|
|
74
|
+
7. If the plugin is already installed but the version is not the latest, run `openclaw plugins update @yahaha-studio/kichi-forwarder`.
|
|
75
|
+
8. If step 6 fails with `429`, do not retry the same bare package command. Run `npm pack @yahaha-studio/kichi-forwarder`, then install the generated `.tgz` with `openclaw plugins install <tgz-path>`.
|
|
76
|
+
9. If step 7 fails with `429`, do not retry the same bare package command. Run `npm pack @yahaha-studio/kichi-forwarder`, then overwrite the existing install with `openclaw plugins install <tgz-path> --force`.
|
|
77
|
+
10. Ensure the plugin is installed, enabled, and at the latest version.
|
|
78
|
+
11. If the plugin was newly installed or upgraded in this flow, re-check workspace `HEARTBEAT.md` against the latest Kichi heartbeat requirements before continuing.
|
|
79
|
+
12. Update workspace `HEARTBEAT.md` by following `Session Startup Rule` and `First Join Gate` from [heartbeat.md](heartbeat.md).
|
|
80
|
+
13. If `HEARTBEAT.md` was not updated successfully, report setup as incomplete and stop immediately. Do not continue to `kichi_status` or `kichi_join`.
|
|
81
|
+
14. Call `kichi_status`.
|
|
82
|
+
15. If the current agent runtime host does not match the requested one, call `kichi_switch_host`.
|
|
83
|
+
16. If `authKey` is missing, call `kichi_join` with parsed `avatarId`, `botName`, `bio`, and `tags`.
|
|
84
|
+
17. Call `kichi_status` again and confirm connection and auth state.
|
|
80
85
|
|
|
81
86
|
## Required Post-install Integration
|
|
82
87
|
|
|
83
88
|
Use this completion checklist:
|
|
84
89
|
|
|
85
|
-
- [ ] plugin installed and
|
|
90
|
+
- [ ] plugin installed, enabled, and at latest version
|
|
86
91
|
- [ ] `HEARTBEAT.md` updated with the Kichi heartbeat workflow snippet from [heartbeat.md](heartbeat.md)
|
|
87
92
|
- [ ] `kichi_status` verified the final connected/auth state
|
|
88
93
|
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { Logger } from "openclaw/plugin-sdk";
|
|
5
|
+
import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
|
|
6
|
+
import { KichiForwarderService } from "./service.js";
|
|
7
|
+
|
|
8
|
+
const OPENCLAW_HOME_DIR = path.join(os.homedir(), ".openclaw");
|
|
9
|
+
const KICHI_WORLD_ROOT_DIR = path.join(OPENCLAW_HOME_DIR, "kichi-world");
|
|
10
|
+
const CANONICAL_AGENT_ROOT_DIR = path.join(KICHI_WORLD_ROOT_DIR, "agents");
|
|
11
|
+
const PREVIOUS_AGENT_ROOT_DIR = path.join(OPENCLAW_HOME_DIR, "kichi-forwarder", "agents");
|
|
12
|
+
const LEGACY_GLOBAL_STATE_PATH = path.join(KICHI_WORLD_ROOT_DIR, "state.json");
|
|
13
|
+
const LEGACY_GLOBAL_HOSTS_DIR = path.join(KICHI_WORLD_ROOT_DIR, "hosts");
|
|
14
|
+
const LEGACY_MIGRATION_AGENT_ID = "main";
|
|
15
|
+
|
|
16
|
+
type AgentLocator = {
|
|
17
|
+
agentId?: string;
|
|
18
|
+
sessionKey?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type GetRuntimeOptions = {
|
|
22
|
+
createIfMissing?: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export class KichiRuntimeManager {
|
|
26
|
+
private services = new Map<string, KichiForwarderService>();
|
|
27
|
+
|
|
28
|
+
constructor(private logger: Logger) {}
|
|
29
|
+
|
|
30
|
+
getRuntime(locator: AgentLocator, options: GetRuntimeOptions = {}): KichiForwarderService | null {
|
|
31
|
+
const agentId = this.resolveAgentId(locator);
|
|
32
|
+
if (!agentId) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const existing = this.services.get(agentId);
|
|
37
|
+
if (existing) {
|
|
38
|
+
return existing;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!options.createIfMissing && !this.hasPersistedRuntime(agentId)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return this.createRuntime(agentId);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
startPersistedRuntimes(): void {
|
|
49
|
+
this.migrateRuntimeStorage();
|
|
50
|
+
|
|
51
|
+
const rootDir = CANONICAL_AGENT_ROOT_DIR;
|
|
52
|
+
if (!fs.existsSync(rootDir)) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
|
|
57
|
+
if (!entry.isDirectory()) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const runtimeDir = path.join(rootDir, entry.name);
|
|
61
|
+
const statePath = path.join(runtimeDir, "state.json");
|
|
62
|
+
if (!fs.existsSync(statePath)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const agentId = decodeURIComponent(entry.name);
|
|
67
|
+
if (this.services.has(agentId)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
this.createRuntime(agentId);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
stopAll(): void {
|
|
76
|
+
for (const service of this.services.values()) {
|
|
77
|
+
service.stop();
|
|
78
|
+
}
|
|
79
|
+
this.services.clear();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private resolveAgentId(locator: AgentLocator): string | null {
|
|
83
|
+
if (typeof locator.agentId === "string" && locator.agentId.trim()) {
|
|
84
|
+
return locator.agentId.trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (typeof locator.sessionKey !== "string" || !locator.sessionKey.trim()) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const agentId = resolveAgentIdFromSessionKey(locator.sessionKey);
|
|
93
|
+
return typeof agentId === "string" && agentId.trim() ? agentId.trim() : null;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private hasPersistedRuntime(agentId: string): boolean {
|
|
100
|
+
return fs.existsSync(path.join(this.getRuntimeDir(agentId), "state.json"));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private migrateRuntimeStorage(): void {
|
|
104
|
+
// Temporary startup migration for this release. Remove after users have
|
|
105
|
+
// moved off the legacy/global layout and the temporary kichi-forwarder path.
|
|
106
|
+
this.runMigrationStep("previous-agent-root", () => {
|
|
107
|
+
this.migratePreviousAgentRoot();
|
|
108
|
+
});
|
|
109
|
+
this.runMigrationStep("legacy-global-root", () => {
|
|
110
|
+
this.migrateLegacyGlobalRoot();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private migratePreviousAgentRoot(): void {
|
|
115
|
+
if (!fs.existsSync(PREVIOUS_AGENT_ROOT_DIR)) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!fs.existsSync(CANONICAL_AGENT_ROOT_DIR)) {
|
|
120
|
+
fs.mkdirSync(path.dirname(CANONICAL_AGENT_ROOT_DIR), { recursive: true, mode: 0o700 });
|
|
121
|
+
fs.renameSync(PREVIOUS_AGENT_ROOT_DIR, CANONICAL_AGENT_ROOT_DIR);
|
|
122
|
+
this.logger.info(`[kichi:migration] moved ${PREVIOUS_AGENT_ROOT_DIR} to ${CANONICAL_AGENT_ROOT_DIR}`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const entry of fs.readdirSync(PREVIOUS_AGENT_ROOT_DIR, { withFileTypes: true })) {
|
|
127
|
+
const sourcePath = path.join(PREVIOUS_AGENT_ROOT_DIR, entry.name);
|
|
128
|
+
const targetPath = path.join(CANONICAL_AGENT_ROOT_DIR, entry.name);
|
|
129
|
+
this.movePathIntoTarget(sourcePath, targetPath);
|
|
130
|
+
}
|
|
131
|
+
this.removeDirectoryIfEmpty(PREVIOUS_AGENT_ROOT_DIR);
|
|
132
|
+
this.removeDirectoryIfEmpty(path.dirname(PREVIOUS_AGENT_ROOT_DIR));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private migrateLegacyGlobalRoot(): void {
|
|
136
|
+
const hasLegacyState = fs.existsSync(LEGACY_GLOBAL_STATE_PATH);
|
|
137
|
+
const hasLegacyHosts = fs.existsSync(LEGACY_GLOBAL_HOSTS_DIR);
|
|
138
|
+
if (!hasLegacyState && !hasLegacyHosts) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const targetRuntimeDir = this.getRuntimeDir(LEGACY_MIGRATION_AGENT_ID);
|
|
143
|
+
fs.mkdirSync(targetRuntimeDir, { recursive: true, mode: 0o700 });
|
|
144
|
+
|
|
145
|
+
if (hasLegacyState) {
|
|
146
|
+
const targetStatePath = path.join(targetRuntimeDir, "state.json");
|
|
147
|
+
this.movePathIntoTarget(LEGACY_GLOBAL_STATE_PATH, targetStatePath);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (hasLegacyHosts) {
|
|
151
|
+
const targetHostsDir = path.join(targetRuntimeDir, "hosts");
|
|
152
|
+
this.movePathIntoTarget(LEGACY_GLOBAL_HOSTS_DIR, targetHostsDir);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private movePathIntoTarget(sourcePath: string, targetPath: string): void {
|
|
157
|
+
if (!fs.existsSync(sourcePath)) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!fs.existsSync(targetPath)) {
|
|
162
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o700 });
|
|
163
|
+
fs.renameSync(sourcePath, targetPath);
|
|
164
|
+
this.logger.info(`[kichi:migration] moved ${sourcePath} to ${targetPath}`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const sourceStat = fs.lstatSync(sourcePath);
|
|
169
|
+
const targetStat = fs.lstatSync(targetPath);
|
|
170
|
+
|
|
171
|
+
if (sourceStat.isDirectory() && targetStat.isDirectory()) {
|
|
172
|
+
for (const entry of fs.readdirSync(sourcePath, { withFileTypes: true })) {
|
|
173
|
+
const nextSourcePath = path.join(sourcePath, entry.name);
|
|
174
|
+
const nextTargetPath = path.join(targetPath, entry.name);
|
|
175
|
+
this.movePathIntoTarget(nextSourcePath, nextTargetPath);
|
|
176
|
+
}
|
|
177
|
+
this.removeDirectoryIfEmpty(sourcePath);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
fs.rmSync(sourcePath, { recursive: sourceStat.isDirectory(), force: true });
|
|
182
|
+
this.logger.warn(`[kichi:migration] dropped ${sourcePath} because target already exists at ${targetPath}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private removeDirectoryIfEmpty(dirPath: string): void {
|
|
186
|
+
if (!fs.existsSync(dirPath)) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (!fs.lstatSync(dirPath).isDirectory()) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (fs.readdirSync(dirPath).length > 0) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
fs.rmdirSync(dirPath);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private runMigrationStep(label: string, fn: () => void): void {
|
|
199
|
+
try {
|
|
200
|
+
fn();
|
|
201
|
+
} catch (error) {
|
|
202
|
+
this.logger.warn(`[kichi:migration] skipped ${label} due to error: ${String(error)}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private createRuntime(agentId: string): KichiForwarderService {
|
|
207
|
+
const runtimeDir = this.getRuntimeDir(agentId);
|
|
208
|
+
fs.mkdirSync(runtimeDir, { recursive: true, mode: 0o700 });
|
|
209
|
+
|
|
210
|
+
const service = new KichiForwarderService(this.logger, {
|
|
211
|
+
agentId,
|
|
212
|
+
runtimeDir,
|
|
213
|
+
});
|
|
214
|
+
service.start();
|
|
215
|
+
this.services.set(agentId, service);
|
|
216
|
+
this.logger.debug(`[kichi:${agentId}] runtime initialized at ${runtimeDir}`);
|
|
217
|
+
return service;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private getRuntimeDir(agentId: string): string {
|
|
221
|
+
return path.join(CANONICAL_AGENT_ROOT_DIR, encodeURIComponent(agentId));
|
|
222
|
+
}
|
|
223
|
+
}
|