u-foo 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.
Files changed (77) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +163 -0
  3. package/README.zh-CN.md +163 -0
  4. package/bin/uclaude +65 -0
  5. package/bin/ucodex +65 -0
  6. package/bin/ufoo +93 -0
  7. package/bin/ufoo.js +35 -0
  8. package/modules/AGENTS.template.md +87 -0
  9. package/modules/bus/README.md +132 -0
  10. package/modules/bus/SKILLS/ubus/SKILL.md +209 -0
  11. package/modules/bus/scripts/bus-alert.sh +185 -0
  12. package/modules/bus/scripts/bus-listen.sh +117 -0
  13. package/modules/context/ASSUMPTIONS.md +7 -0
  14. package/modules/context/CONSTRAINTS.md +7 -0
  15. package/modules/context/CONTEXT-STRUCTURE.md +49 -0
  16. package/modules/context/DECISION-PROTOCOL.md +62 -0
  17. package/modules/context/HANDOFF.md +33 -0
  18. package/modules/context/README.md +82 -0
  19. package/modules/context/RULES.md +15 -0
  20. package/modules/context/SKILLS/README.md +14 -0
  21. package/modules/context/SKILLS/uctx/SKILL.md +91 -0
  22. package/modules/context/SYSTEM.md +18 -0
  23. package/modules/context/TEMPLATES/assumptions.md +4 -0
  24. package/modules/context/TEMPLATES/constraints.md +4 -0
  25. package/modules/context/TEMPLATES/decision.md +16 -0
  26. package/modules/context/TEMPLATES/project-context-readme.md +6 -0
  27. package/modules/context/TEMPLATES/system.md +3 -0
  28. package/modules/context/TEMPLATES/terminology.md +4 -0
  29. package/modules/context/TERMINOLOGY.md +10 -0
  30. package/modules/resources/ICONS/README.md +12 -0
  31. package/modules/resources/ICONS/libraries/README.md +17 -0
  32. package/modules/resources/ICONS/libraries/heroicons/LICENSE +22 -0
  33. package/modules/resources/ICONS/libraries/heroicons/README.md +15 -0
  34. package/modules/resources/ICONS/libraries/heroicons/arrow-right.svg +4 -0
  35. package/modules/resources/ICONS/libraries/heroicons/check.svg +4 -0
  36. package/modules/resources/ICONS/libraries/heroicons/chevron-down.svg +4 -0
  37. package/modules/resources/ICONS/libraries/heroicons/cog-6-tooth.svg +5 -0
  38. package/modules/resources/ICONS/libraries/heroicons/magnifying-glass.svg +4 -0
  39. package/modules/resources/ICONS/libraries/heroicons/x-mark.svg +4 -0
  40. package/modules/resources/ICONS/libraries/lucide/LICENSE +40 -0
  41. package/modules/resources/ICONS/libraries/lucide/README.md +15 -0
  42. package/modules/resources/ICONS/libraries/lucide/arrow-right.svg +15 -0
  43. package/modules/resources/ICONS/libraries/lucide/check.svg +14 -0
  44. package/modules/resources/ICONS/libraries/lucide/chevron-down.svg +14 -0
  45. package/modules/resources/ICONS/libraries/lucide/search.svg +15 -0
  46. package/modules/resources/ICONS/libraries/lucide/settings.svg +15 -0
  47. package/modules/resources/ICONS/libraries/lucide/x.svg +15 -0
  48. package/modules/resources/ICONS/rules.md +7 -0
  49. package/modules/resources/README.md +9 -0
  50. package/modules/resources/UI/ANTI-PATTERNS.md +6 -0
  51. package/modules/resources/UI/TONE.md +6 -0
  52. package/package.json +40 -0
  53. package/scripts/banner.sh +89 -0
  54. package/scripts/bus-alert.sh +6 -0
  55. package/scripts/bus-autotrigger.sh +6 -0
  56. package/scripts/bus-daemon.sh +231 -0
  57. package/scripts/bus-inject.sh +144 -0
  58. package/scripts/bus-listen.sh +6 -0
  59. package/scripts/bus.sh +984 -0
  60. package/scripts/context-decisions.sh +167 -0
  61. package/scripts/context-doctor.sh +72 -0
  62. package/scripts/context-lint.sh +110 -0
  63. package/scripts/doctor.sh +22 -0
  64. package/scripts/init.sh +247 -0
  65. package/scripts/skills.sh +113 -0
  66. package/scripts/status.sh +125 -0
  67. package/src/agent/cliRunner.js +190 -0
  68. package/src/agent/internalRunner.js +212 -0
  69. package/src/agent/normalizeOutput.js +41 -0
  70. package/src/agent/ufooAgent.js +222 -0
  71. package/src/chat/index.js +1603 -0
  72. package/src/cli.js +349 -0
  73. package/src/config.js +37 -0
  74. package/src/daemon/index.js +501 -0
  75. package/src/daemon/ops.js +120 -0
  76. package/src/daemon/run.js +41 -0
  77. package/src/daemon/status.js +78 -0
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+
6
+ skill_roots=()
7
+ if [[ -d "$repo_root/SKILLS" ]]; then
8
+ skill_roots+=("$repo_root/SKILLS")
9
+ fi
10
+ if [[ -d "$repo_root/modules" ]]; then
11
+ while IFS= read -r d; do
12
+ skill_roots+=("$d")
13
+ done < <(find "$repo_root/modules" -maxdepth 2 -type d -name SKILLS | sort)
14
+ fi
15
+
16
+ usage() {
17
+ cat <<'EOFU'
18
+ skills
19
+
20
+ Usage:
21
+ ufoo skills list
22
+ ufoo skills install <name|all> [--target <dir> | --codex | --agents]
23
+ EOFU
24
+ }
25
+
26
+ cmd="${1:-}"
27
+ shift || true
28
+
29
+ case "$cmd" in
30
+ list)
31
+ for root in "${skill_roots[@]}"; do
32
+ find "$root" -mindepth 1 -maxdepth 1 -type d -print
33
+ done | sed 's|.*/||' | sort -u
34
+ ;;
35
+ install)
36
+ name="${1:-}"
37
+ shift || true
38
+ if [[ -z "$name" ]]; then
39
+ echo "FAIL: install requires <name|all>" >&2
40
+ exit 1
41
+ fi
42
+
43
+ target=""
44
+ while [[ $# -gt 0 ]]; do
45
+ case "$1" in
46
+ --target)
47
+ target="$2"; shift 2 ;;
48
+ --codex)
49
+ target="${CODEX_HOME:-$HOME/.codex}/skills"; shift ;;
50
+ --agents)
51
+ target="$HOME/.agents/skills"; shift ;;
52
+ *)
53
+ echo "Unknown option: $1" >&2
54
+ usage >&2
55
+ exit 1
56
+ ;;
57
+ esac
58
+ done
59
+
60
+ if [[ -z "$target" ]]; then
61
+ echo "FAIL: specify --target/--codex/--agents" >&2
62
+ exit 1
63
+ fi
64
+
65
+ mkdir -p "$target"
66
+
67
+ find_skill() {
68
+ local skill_name="$1"
69
+ local matches=()
70
+ local root
71
+ for root in "${skill_roots[@]}"; do
72
+ if [[ -f "$root/$skill_name/SKILL.md" ]]; then
73
+ matches+=("$root/$skill_name")
74
+ fi
75
+ done
76
+ if (( ${#matches[@]} == 0 )); then
77
+ echo "FAIL: missing skill '$skill_name'" >&2
78
+ exit 1
79
+ fi
80
+ if (( ${#matches[@]} > 1 )); then
81
+ echo "FAIL: duplicate skill name '$skill_name' in:" >&2
82
+ printf ' - %s\n' "${matches[@]}" >&2
83
+ exit 1
84
+ fi
85
+ echo "${matches[0]}"
86
+ }
87
+
88
+ install_one() {
89
+ local skill_name="$1"
90
+ local src_dir
91
+ src_dir="$(find_skill "$skill_name")"
92
+ local src_file="$src_dir/SKILL.md"
93
+ local dst_dir="$target/$skill_name"
94
+ local dst_file="$dst_dir/SKILL.md"
95
+
96
+ mkdir -p "$dst_dir"
97
+ cp "$src_file" "$dst_file"
98
+ echo "Installed: $skill_name"
99
+ }
100
+
101
+ if [[ "$name" == "all" ]]; then
102
+ while IFS= read -r d; do
103
+ install_one "$d"
104
+ done < <(for root in "${skill_roots[@]}"; do find "$root" -mindepth 1 -maxdepth 1 -type d -print; done | sed 's|.*/||' | sort -u)
105
+ else
106
+ install_one "$name"
107
+ fi
108
+ ;;
109
+ *)
110
+ usage
111
+ exit 1
112
+ ;;
113
+ esac
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ if [[ ! -d ".ufoo" ]]; then
5
+ echo "FAIL: .ufoo not found. Run: ufoo init" >&2
6
+ exit 1
7
+ fi
8
+
9
+ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
10
+
11
+ if [[ -f "$repo_root/scripts/banner.sh" ]]; then
12
+ # shellcheck disable=SC1090
13
+ source "$repo_root/scripts/banner.sh"
14
+ fi
15
+
16
+ agent_type="claude"
17
+ session_id="${CLAUDE_SESSION_ID:-}"
18
+ if [[ -n "${CODEX_SESSION_ID:-}" ]]; then
19
+ agent_type="codex"
20
+ session_id="$CODEX_SESSION_ID"
21
+ fi
22
+
23
+ subscriber=""
24
+ if [[ -f ".ufoo/bus/bus.json" ]]; then
25
+ cur_tty="$(tty 2>/dev/null || true)"
26
+ if [[ "$cur_tty" == /dev/* ]]; then
27
+ subscriber="$(
28
+ jq -r --arg tty "$cur_tty" \
29
+ '.subscribers | to_entries[] | select(.value.tty == $tty) | .key' \
30
+ .ufoo/bus/bus.json 2>/dev/null | head -1
31
+ )"
32
+ fi
33
+ fi
34
+
35
+ if [[ -z "$subscriber" && -n "$session_id" ]]; then
36
+ if [[ "$agent_type" == "codex" ]]; then
37
+ subscriber="codex:$session_id"
38
+ else
39
+ subscriber="claude-code:$session_id"
40
+ fi
41
+ fi
42
+
43
+ if [[ -z "$session_id" && "$subscriber" == *:* ]]; then
44
+ session_id="${subscriber#*:}"
45
+ fi
46
+ if [[ -z "$session_id" ]]; then
47
+ session_id="unknown"
48
+ fi
49
+
50
+ if declare -F show_banner >/dev/null 2>&1; then
51
+ show_banner "$agent_type" "$session_id" "$subscriber"
52
+ else
53
+ echo "=== ufoo status ==="
54
+ echo "Agent: ${subscriber:-$agent_type}"
55
+ echo ""
56
+ fi
57
+
58
+ echo "Project: $(pwd)"
59
+
60
+ unread_total=0
61
+ unread_lines=""
62
+ if [[ -d ".ufoo/bus/queues" ]]; then
63
+ shopt -s nullglob
64
+ for queue_file in .ufoo/bus/queues/*/pending.jsonl; do
65
+ [[ -s "$queue_file" ]] || continue
66
+ count=$(wc -l < "$queue_file" | tr -d ' ')
67
+ unread_total=$((unread_total + count))
68
+ safe_name="$(basename "$(dirname "$queue_file")")"
69
+ subscriber_name="$safe_name"
70
+ if [[ -f ".ufoo/bus/bus.json" ]]; then
71
+ subscriber_name="$(
72
+ jq -r --arg safe "$safe_name" \
73
+ '.subscribers | to_entries[] | select((.key|gsub(":";"_")) == $safe) | .key' \
74
+ .ufoo/bus/bus.json 2>/dev/null | head -1
75
+ )"
76
+ [[ -n "$subscriber_name" && "$subscriber_name" != "null" ]] || subscriber_name="$safe_name"
77
+ else
78
+ subscriber_name="${safe_name/_/:}"
79
+ fi
80
+ unread_lines+=$' - '"$subscriber_name"$': '"$count"$'\n'
81
+ done
82
+ shopt -u nullglob
83
+ fi
84
+
85
+ echo "Unread messages: $unread_total"
86
+ if [[ -n "$unread_lines" ]]; then
87
+ printf "%s" "$unread_lines"
88
+ fi
89
+
90
+ decisions_dir=".ufoo/context/DECISIONS"
91
+ open_count=0
92
+ open_lines=""
93
+
94
+ get_status() {
95
+ local file="$1"
96
+ local status
97
+ if grep -q "^---$" "$file"; then
98
+ status=$(awk '/^---$/{if(++c==2)exit} c==1 && /^status:/{print $2}' "$file")
99
+ fi
100
+ echo "${status:-open}"
101
+ }
102
+
103
+ get_title() {
104
+ awk '/^#/{sub(/^# */,"");print; exit}' "$1"
105
+ }
106
+
107
+ if [[ -d "$decisions_dir" ]]; then
108
+ shopt -s nullglob
109
+ for f in "$decisions_dir"/*.md; do
110
+ [[ -f "$f" ]] || continue
111
+ status="$(get_status "$f")"
112
+ if [[ "$status" == "open" ]]; then
113
+ open_count=$((open_count + 1))
114
+ title="$(get_title "$f")"
115
+ [[ -z "$title" ]] && title="(no title)"
116
+ open_lines+=$' - '"$(basename "$f")"$': '"$title"$'\n'
117
+ fi
118
+ done
119
+ shopt -u nullglob
120
+ fi
121
+
122
+ echo "Open decisions: $open_count"
123
+ if [[ -n "$open_lines" ]]; then
124
+ printf "%s" "$open_lines"
125
+ fi
@@ -0,0 +1,190 @@
1
+ const { spawn } = require("child_process");
2
+ const { randomUUID } = require("crypto");
3
+
4
+ function collectJsonl(text) {
5
+ const lines = text.split(/\r?\n/).filter((l) => l.trim());
6
+ const items = [];
7
+ for (const line of lines) {
8
+ try {
9
+ items.push(JSON.parse(line));
10
+ } catch {
11
+ // Ignore malformed lines
12
+ }
13
+ }
14
+ return items;
15
+ }
16
+
17
+ function collectJson(text) {
18
+ try {
19
+ return JSON.parse(text);
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ function runCommand(command, args, options = {}) {
26
+ return new Promise((resolve, reject) => {
27
+ const child = spawn(command, args, {
28
+ stdio: ["pipe", "pipe", "pipe"],
29
+ ...options,
30
+ });
31
+
32
+ let stdout = "";
33
+ let stderr = "";
34
+ child.stdout.on("data", (d) => {
35
+ stdout += d.toString("utf8");
36
+ });
37
+ child.stderr.on("data", (d) => {
38
+ stderr += d.toString("utf8");
39
+ });
40
+ let timeout = null;
41
+ if (options.timeoutMs) {
42
+ timeout = setTimeout(() => {
43
+ try {
44
+ child.kill("SIGTERM");
45
+ } catch {
46
+ // ignore
47
+ }
48
+ reject(new Error("CLI timeout"));
49
+ }, options.timeoutMs);
50
+ }
51
+
52
+ child.on("error", (err) => {
53
+ if (timeout) clearTimeout(timeout);
54
+ reject(err);
55
+ });
56
+ child.on("close", (code) => {
57
+ if (timeout) clearTimeout(timeout);
58
+ resolve({ code, stdout, stderr });
59
+ });
60
+
61
+ if (options.input) {
62
+ child.stdin.write(options.input);
63
+ }
64
+ child.stdin.end();
65
+ });
66
+ }
67
+
68
+ const DEFAULT_CLAUDE = {
69
+ command: "claude",
70
+ args: [
71
+ "-p",
72
+ "--output-format",
73
+ "json",
74
+ "--dangerously-skip-permissions",
75
+ "--no-session-persistence",
76
+ "--json-schema",
77
+ '{"type":"object","properties":{"reply":{"type":"string"},"dispatch":{"type":"array","items":{"type":"object","properties":{"target":{"type":"string"},"message":{"type":"string"}},"required":["target","message"]}},"ops":{"type":"array","items":{"type":"object","properties":{"action":{"type":"string"},"agent":{"type":"string"},"count":{"type":"integer"},"agent_id":{"type":"string"},"nickname":{"type":"string"}},"required":["action"]}},"disambiguate":{"type":"object","properties":{"prompt":{"type":"string"},"candidates":{"type":"array","items":{"type":"object","properties":{"agent_id":{"type":"string"},"reason":{"type":"string"}},"required":["agent_id"]}}}}},"required":["reply","dispatch","ops"]}',
78
+ ],
79
+ output: "json",
80
+ input: "arg",
81
+ modelArg: "--model",
82
+ sessionArg: "--session-id",
83
+ systemPromptArg: "--append-system-prompt",
84
+ };
85
+
86
+ const DEFAULT_CODEX = {
87
+ command: "codex",
88
+ args: ["exec", "--json", "--color", "never", "--sandbox", "read-only", "--skip-git-repo-check"],
89
+ output: "jsonl",
90
+ input: "arg",
91
+ modelArg: "--model",
92
+ sessionArg: null,
93
+ fallbackArgs: ["exec", "--json", "--color", "never", "--sandbox", "read-only"],
94
+ };
95
+
96
+ function buildArgs(backend, prompt, opts) {
97
+ const args = [...(backend.args || [])];
98
+ if (opts.model && backend.modelArg) {
99
+ args.push(backend.modelArg, opts.model);
100
+ }
101
+ if (opts.sessionId && backend.sessionArg && !opts.disableSession) {
102
+ args.push(backend.sessionArg, opts.sessionId);
103
+ }
104
+ if (opts.systemPrompt && backend.systemPromptArg) {
105
+ args.push(backend.systemPromptArg, opts.systemPrompt);
106
+ }
107
+ if (backend.input === "arg") {
108
+ args.push(prompt);
109
+ return { args, stdin: "" };
110
+ }
111
+ return { args, stdin: prompt };
112
+ }
113
+
114
+ function isUnsupportedArgError(errText) {
115
+ const text = (errText || "").toLowerCase();
116
+ return text.includes("unknown option")
117
+ || text.includes("unknown argument")
118
+ || text.includes("unexpected argument")
119
+ || text.includes("unrecognized option");
120
+ }
121
+
122
+ async function runCliAgent(params) {
123
+ const backend = params.provider === "codex-cli" ? DEFAULT_CODEX : DEFAULT_CLAUDE;
124
+ const sessionId = params.sessionId || randomUUID();
125
+ const prompt =
126
+ params.systemPrompt && !backend.systemPromptArg
127
+ ? `${params.systemPrompt}\n\n${params.prompt}`
128
+ : params.prompt;
129
+ const { args, stdin } = buildArgs(backend, prompt, {
130
+ model: params.model,
131
+ sessionId,
132
+ systemPrompt: params.systemPrompt,
133
+ disableSession: params.disableSession,
134
+ });
135
+
136
+ let res;
137
+ const env = { ...process.env, ...(params.env || {}) };
138
+ delete env.CLAUDE_SESSION_ID;
139
+ delete env.CODEX_SESSION_ID;
140
+ try {
141
+ res = await runCommand(backend.command, args, {
142
+ cwd: params.cwd,
143
+ env,
144
+ input: stdin,
145
+ timeoutMs: params.timeoutMs || 300000, // 5 minutes for complex tasks
146
+ });
147
+ } catch (err) {
148
+ return { ok: false, error: err.message || String(err), sessionId };
149
+ }
150
+
151
+ if (res.code !== 0) {
152
+ const err = res.stderr || res.stdout || "CLI failed";
153
+ if (backend === DEFAULT_CODEX && backend.fallbackArgs && isUnsupportedArgError(err)) {
154
+ const retry = buildArgs(
155
+ { ...backend, args: backend.fallbackArgs },
156
+ prompt,
157
+ {
158
+ model: params.model,
159
+ sessionId,
160
+ systemPrompt: params.systemPrompt,
161
+ disableSession: params.disableSession,
162
+ },
163
+ );
164
+ try {
165
+ res = await runCommand(backend.command, retry.args, {
166
+ cwd: params.cwd,
167
+ env,
168
+ input: retry.stdin,
169
+ timeoutMs: params.timeoutMs || 60000,
170
+ });
171
+ } catch (err2) {
172
+ return { ok: false, error: err2.message || String(err2), sessionId };
173
+ }
174
+ if (res.code !== 0) {
175
+ const err2 = res.stderr || res.stdout || "CLI failed";
176
+ return { ok: false, error: err2, sessionId };
177
+ }
178
+ } else {
179
+ return { ok: false, error: err, sessionId };
180
+ }
181
+ }
182
+
183
+ if (backend.output === "jsonl") {
184
+ return { ok: true, sessionId, output: collectJsonl(res.stdout) };
185
+ }
186
+
187
+ return { ok: true, sessionId, output: collectJson(res.stdout) };
188
+ }
189
+
190
+ module.exports = { runCliAgent };
@@ -0,0 +1,212 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { spawnSync } = require("child_process");
4
+ const { randomBytes } = require("crypto");
5
+ const { runCliAgent } = require("./cliRunner");
6
+ const { normalizeCliOutput } = require("./normalizeOutput");
7
+
8
+ function sleep(ms) {
9
+ return new Promise((resolve) => setTimeout(resolve, ms));
10
+ }
11
+
12
+ function generateSessionId() {
13
+ return randomBytes(4).toString("hex");
14
+ }
15
+
16
+ function buildEnv(agentType, sessionId, publisher, nickname) {
17
+ const env = { ...process.env };
18
+ if (agentType === "codex") {
19
+ env.CODEX_SESSION_ID = sessionId;
20
+ env.CLAUDE_SESSION_ID = "";
21
+ } else {
22
+ env.CLAUDE_SESSION_ID = sessionId;
23
+ env.CODEX_SESSION_ID = "";
24
+ }
25
+ env.AI_BUS_PUBLISHER = publisher || env.AI_BUS_PUBLISHER || "";
26
+ env.UFOO_NICKNAME = nickname || env.UFOO_NICKNAME || "";
27
+ env.UFOO_PARENT_PID = String(process.pid);
28
+ return env;
29
+ }
30
+
31
+ function joinBus(projectRoot, agentType, sessionId, nickname) {
32
+ const env = buildEnv(agentType, sessionId, "", nickname);
33
+ const args = ["bus", "join", sessionId, agentType === "codex" ? "codex" : "claude-code"];
34
+ if (nickname) args.push(nickname);
35
+ const res = spawnSync("ufoo", args, {
36
+ cwd: projectRoot,
37
+ env,
38
+ stdio: ["ignore", "pipe", "pipe"],
39
+ });
40
+ if (res.status !== 0) {
41
+ const err = (res.stderr || res.stdout || "").toString("utf8").trim();
42
+ throw new Error(err || "bus join failed");
43
+ }
44
+ const out = (res.stdout || "").toString("utf8").trim().split(/\r?\n/);
45
+ const subscriber = out[out.length - 1];
46
+ return { subscriber, env };
47
+ }
48
+
49
+ function safeSubscriber(subscriber) {
50
+ return subscriber.replace(/:/g, "_");
51
+ }
52
+
53
+ function readQueue(queueFile) {
54
+ if (!fs.existsSync(queueFile)) return [];
55
+ try {
56
+ const content = fs.readFileSync(queueFile, "utf8");
57
+ if (!content.trim()) return [];
58
+ return content.split(/\r?\n/).filter(Boolean);
59
+ } catch {
60
+ return [];
61
+ }
62
+ }
63
+
64
+ function truncateQueue(queueFile) {
65
+ try {
66
+ fs.truncateSync(queueFile, 0);
67
+ } catch {
68
+ // ignore
69
+ }
70
+ }
71
+
72
+ async function handleEvent(projectRoot, agentType, provider, model, subscriber, sessionId, nickname, evt, cliSessionState) {
73
+ if (!evt || !evt.data || !evt.data.message) return;
74
+ const prompt = evt.data.message;
75
+ const publisher = evt.publisher || "unknown";
76
+
77
+ let res = await runCliAgent({
78
+ provider,
79
+ model,
80
+ prompt,
81
+ sessionId: cliSessionState.cliSessionId,
82
+ cwd: projectRoot,
83
+ });
84
+
85
+ // Handle session errors with immediate retry (only for claude)
86
+ if (!res.ok && provider === "claude-cli") {
87
+ const errMsg = (res.error || "").toLowerCase();
88
+ if (errMsg.includes("session") || errMsg.includes("already in use")) {
89
+ // Clear session and retry immediately with new session
90
+ cliSessionState.cliSessionId = null;
91
+ cliSessionState.needsSave = true;
92
+
93
+ res = await runCliAgent({
94
+ provider,
95
+ model,
96
+ prompt,
97
+ sessionId: null, // Let runCliAgent generate new session
98
+ cwd: projectRoot,
99
+ });
100
+ }
101
+ }
102
+
103
+ // Update CLI session ID for continuity (only for claude)
104
+ if (res.ok && res.sessionId && provider === "claude-cli") {
105
+ cliSessionState.cliSessionId = res.sessionId;
106
+ cliSessionState.needsSave = true;
107
+ }
108
+
109
+ let reply = "";
110
+ if (res.ok) {
111
+ reply = normalizeCliOutput(res.output) || "";
112
+ } else {
113
+ reply = `[internal:${agentType}] error: ${res.error || "unknown error"}`;
114
+ }
115
+
116
+ if (!reply) return;
117
+
118
+ spawnSync("ufoo", ["bus", "send", publisher, reply], {
119
+ cwd: projectRoot,
120
+ env: buildEnv(agentType, sessionId, subscriber, nickname),
121
+ stdio: "ignore",
122
+ });
123
+ }
124
+
125
+ async function runInternalRunner({ projectRoot, agentType = "codex" }) {
126
+ const sessionId = generateSessionId();
127
+ const nickname = process.env.UFOO_NICKNAME || "";
128
+ const { subscriber } = joinBus(projectRoot, agentType, sessionId, nickname);
129
+ if (!subscriber) {
130
+ throw new Error("Failed to join bus for internal runner");
131
+ }
132
+
133
+ const queueDir = path.join(projectRoot, ".ufoo", "bus", "queues", safeSubscriber(subscriber));
134
+ const queueFile = path.join(queueDir, "pending.jsonl");
135
+ const provider = agentType === "codex" ? "codex-cli" : "claude-cli";
136
+ const model = process.env.UFOO_AGENT_MODEL || "";
137
+
138
+ // Session state management for CLI continuity
139
+ // Use stable path based on nickname (if exists) or agent type, NOT subscriber ID
140
+ const stableKey = nickname || `${agentType}-default`;
141
+ const sessionDir = path.join(projectRoot, ".ufoo", "agent", "sessions");
142
+ fs.mkdirSync(sessionDir, { recursive: true });
143
+ const stateFile = path.join(sessionDir, `${stableKey}.json`);
144
+
145
+ let cliSessionId = null;
146
+ // Only load session for claude (codex doesn't support sessions)
147
+ if (provider === "claude-cli") {
148
+ try {
149
+ const state = JSON.parse(fs.readFileSync(stateFile, "utf8"));
150
+ cliSessionId = state.cliSessionId;
151
+ } catch {
152
+ // No previous session
153
+ }
154
+ }
155
+
156
+ let running = true;
157
+ let processing = false;
158
+
159
+ const stop = () => {
160
+ running = false;
161
+ };
162
+
163
+ process.on("SIGTERM", stop);
164
+ process.on("SIGINT", stop);
165
+
166
+ const cliSessionState = { cliSessionId, needsSave: false };
167
+
168
+ while (running) {
169
+ if (!processing) {
170
+ processing = true;
171
+ try {
172
+ const lines = readQueue(queueFile);
173
+ if (lines.length > 0) {
174
+ const events = [];
175
+ for (const line of lines) {
176
+ try {
177
+ events.push(JSON.parse(line));
178
+ } catch {
179
+ // ignore malformed line
180
+ }
181
+ }
182
+ truncateQueue(queueFile);
183
+
184
+ for (const evt of events) {
185
+ // eslint-disable-next-line no-await-in-loop
186
+ await handleEvent(projectRoot, agentType, provider, model, subscriber, sessionId, nickname, evt, cliSessionState);
187
+ }
188
+
189
+ // Persist CLI session state after processing (only if changed and for claude)
190
+ if (cliSessionState.needsSave && provider === "claude-cli") {
191
+ try {
192
+ fs.writeFileSync(stateFile, JSON.stringify({
193
+ cliSessionId: cliSessionState.cliSessionId,
194
+ nickname: nickname || "",
195
+ updated_at: new Date().toISOString(),
196
+ }));
197
+ cliSessionState.needsSave = false;
198
+ } catch {
199
+ // ignore save errors
200
+ }
201
+ }
202
+ }
203
+ } finally {
204
+ processing = false;
205
+ }
206
+ }
207
+ // eslint-disable-next-line no-await-in-loop
208
+ await sleep(1000);
209
+ }
210
+ }
211
+
212
+ module.exports = { runInternalRunner };
@@ -0,0 +1,41 @@
1
+ function extractTextFromObject(obj) {
2
+ if (!obj || typeof obj !== "object") return "";
3
+ if (obj.structured_output && typeof obj.structured_output === "object") {
4
+ return JSON.stringify(obj.structured_output);
5
+ }
6
+ const candidates = ["output", "text", "message", "content", "output_text", "result"];
7
+ for (const key of candidates) {
8
+ const val = obj[key];
9
+ if (typeof val === "string") return val;
10
+ }
11
+ return "";
12
+ }
13
+
14
+ function normalizeCliOutput(output) {
15
+ if (!output) return "";
16
+ if (typeof output === "string") return output;
17
+ if (Array.isArray(output)) {
18
+ const parts = [];
19
+ for (const item of output) {
20
+ if (typeof item === "string") {
21
+ parts.push(item);
22
+ continue;
23
+ }
24
+ if (item && typeof item === "object") {
25
+ if (item.item && typeof item.item === "object") {
26
+ if (item.item.type === "agent_message" && typeof item.item.text === "string") {
27
+ parts.push(item.item.text);
28
+ continue;
29
+ }
30
+ }
31
+ if (typeof item.text === "string") parts.push(item.text);
32
+ else if (typeof item.content === "string") parts.push(item.content);
33
+ else if (typeof item.output === "string") parts.push(item.output);
34
+ }
35
+ }
36
+ return parts.join("\n").trim();
37
+ }
38
+ return extractTextFromObject(output);
39
+ }
40
+
41
+ module.exports = { normalizeCliOutput };