@gajae-code/coding-agent 0.2.1 → 0.2.2
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/CHANGELOG.md +31 -1
- package/dist/types/commands/contribution-prep.d.ts +18 -0
- package/dist/types/commands/session.d.ts +24 -0
- package/dist/types/config/model-registry.d.ts +2 -2
- package/dist/types/config/models-config-schema.d.ts +17 -9
- package/dist/types/config/settings-schema.d.ts +1 -24
- package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +15 -0
- package/dist/types/gjc-runtime/goal-mode-request.d.ts +1 -1
- package/dist/types/gjc-runtime/launch-tmux.d.ts +12 -11
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +25 -0
- package/dist/types/gjc-runtime/state-runtime.d.ts +13 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +37 -5
- package/dist/types/gjc-runtime/tmux-common.d.ts +41 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +17 -0
- package/dist/types/goals/runtime.d.ts +3 -9
- package/dist/types/goals/state.d.ts +3 -6
- package/dist/types/goals/tools/goal-tool.d.ts +1 -69
- package/dist/types/modes/components/status-line/types.d.ts +0 -3
- package/dist/types/modes/components/status-line.d.ts +0 -3
- package/dist/types/modes/controllers/command-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -12
- package/dist/types/modes/theme/defaults/index.d.ts +0 -2
- package/dist/types/modes/theme/theme.d.ts +1 -2
- package/dist/types/modes/types.d.ts +1 -7
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/contribution-prep.d.ts +47 -0
- package/dist/types/skill-state/active-state.d.ts +4 -0
- package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +6 -1
- package/dist/types/skill-state/workflow-hud.d.ts +9 -4
- package/dist/types/skill-state/workflow-state-contract.d.ts +34 -0
- package/package.json +7 -7
- package/src/cli/args.ts +3 -2
- package/src/cli.ts +6 -1
- package/src/commands/contribution-prep.ts +41 -0
- package/src/commands/deep-interview.ts +6 -22
- package/src/commands/launch.ts +10 -1
- package/src/commands/ralplan.ts +10 -22
- package/src/commands/session.ts +150 -0
- package/src/commands/state.ts +14 -4
- package/src/commands/team.ts +23 -3
- package/src/config/model-registry.ts +10 -2
- package/src/config/models-config-schema.ts +120 -102
- package/src/config/settings-schema.ts +1 -25
- package/src/config.ts +1 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +14 -13
- package/src/defaults/gjc/skills/ralplan/SKILL.md +14 -2
- package/src/defaults/gjc/skills/team/SKILL.md +29 -7
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +23 -25
- package/src/eval/py/prelude.py +1 -1
- package/src/gjc-runtime/deep-interview-runtime.ts +279 -0
- package/src/gjc-runtime/goal-mode-request.ts +2 -19
- package/src/gjc-runtime/launch-tmux.ts +83 -43
- package/src/gjc-runtime/ralplan-runtime.ts +460 -0
- package/src/gjc-runtime/state-runtime.ts +562 -0
- package/src/gjc-runtime/team-runtime.ts +708 -52
- package/src/gjc-runtime/tmux-common.ts +119 -0
- package/src/gjc-runtime/tmux-sessions.ts +165 -0
- package/src/gjc-runtime/ultragoal-guard.ts +6 -3
- package/src/gjc-runtime/ultragoal-runtime.ts +5 -4
- package/src/goals/runtime.ts +38 -144
- package/src/goals/state.ts +36 -7
- package/src/goals/tools/goal-tool.ts +15 -172
- package/src/hooks/skill-state.ts +31 -12
- package/src/internal-urls/docs-index.generated.ts +4 -3
- package/src/modes/components/skill-hud/render.ts +4 -0
- package/src/modes/components/status-line/segments.ts +5 -16
- package/src/modes/components/status-line/types.ts +0 -3
- package/src/modes/components/status-line.ts +0 -6
- package/src/modes/controllers/command-controller.ts +25 -1
- package/src/modes/controllers/input-controller.ts +0 -15
- package/src/modes/interactive-mode.ts +18 -219
- package/src/modes/theme/defaults/dark-poimandres.json +0 -1
- package/src/modes/theme/defaults/light-poimandres.json +0 -1
- package/src/modes/theme/theme.ts +0 -6
- package/src/modes/types.ts +1 -7
- package/src/prompts/goals/goal-continuation.md +1 -4
- package/src/prompts/goals/goal-mode-active.md +3 -5
- package/src/prompts/system/system-prompt.md +5 -7
- package/src/prompts/tools/goal.md +4 -4
- package/src/sdk.ts +1 -1
- package/src/session/agent-session.ts +18 -0
- package/src/session/contribution-prep.ts +320 -0
- package/src/skill-state/active-state.ts +38 -0
- package/src/skill-state/deep-interview-mutation-guard.ts +88 -24
- package/src/skill-state/workflow-hud.ts +23 -5
- package/src/skill-state/workflow-state-contract.ts +121 -0
- package/src/slash-commands/builtin-registry.ts +24 -12
- package/src/task/commands.ts +1 -5
- package/src/tools/gh.ts +212 -2
- package/src/tools/index.ts +2 -5
- package/dist/types/commands/gjc-runtime-bridge.d.ts +0 -30
- package/dist/types/commands/question.d.ts +0 -7
- package/dist/types/modes/loop-limit.d.ts +0 -22
- package/src/commands/gjc-runtime-bridge.ts +0 -227
- package/src/commands/question.ts +0 -12
- package/src/modes/loop-limit.ts +0 -140
- package/src/prompts/commands/orchestrate.md +0 -49
- package/src/prompts/goals/goal-budget-limit.md +0 -16
- package/src/prompts/tools/create-goal.md +0 -3
- package/src/prompts/tools/get-goal.md +0 -3
- package/src/prompts/tools/update-goal.md +0 -3
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { syncSkillActiveState } from "../skill-state/active-state";
|
|
5
|
+
import { buildDeepInterviewHudSummary } from "../skill-state/workflow-hud";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Native implementation of `gjc deep-interview`.
|
|
9
|
+
*
|
|
10
|
+
* The CLI itself does not run the Socratic interview; that lives inside the `/skill:deep-interview`
|
|
11
|
+
* skill executed by the agent. This handler validates the documented argument-hint surface
|
|
12
|
+
* (`[--quick|--standard|--deep] <idea>`), seeds `.gjc/state/deep-interview-state.json`, and
|
|
13
|
+
* updates the shared HUD rail via `syncSkillActiveState` so the active interview is visible to
|
|
14
|
+
* the TUI.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface DeepInterviewCommandResult {
|
|
18
|
+
status: number;
|
|
19
|
+
stdout?: string;
|
|
20
|
+
stderr?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const PATH_COMPONENT_RE = /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$/;
|
|
24
|
+
|
|
25
|
+
const DEFAULT_AMBIGUITY_THRESHOLD = 0.05;
|
|
26
|
+
|
|
27
|
+
const RESOLUTION_THRESHOLDS = {
|
|
28
|
+
quick: 0.6,
|
|
29
|
+
standard: 0.5,
|
|
30
|
+
deep: 0.35,
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
type DeepInterviewResolution = keyof typeof RESOLUTION_THRESHOLDS;
|
|
34
|
+
|
|
35
|
+
class DeepInterviewCommandError extends Error {
|
|
36
|
+
constructor(
|
|
37
|
+
public readonly exitStatus: number,
|
|
38
|
+
message: string,
|
|
39
|
+
) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "DeepInterviewCommandError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const VALUE_FLAGS = new Set(["--session-id", "--threshold", "--threshold-source"]);
|
|
46
|
+
|
|
47
|
+
function flagValue(args: readonly string[], flag: string): string | undefined {
|
|
48
|
+
const index = args.indexOf(flag);
|
|
49
|
+
if (index < 0) return undefined;
|
|
50
|
+
return args[index + 1];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hasFlag(args: readonly string[], flag: string): boolean {
|
|
54
|
+
return args.includes(flag);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function assertSafePathComponent(value: string, label: string): void {
|
|
58
|
+
if (!PATH_COMPONENT_RE.test(value) || value.includes("..")) {
|
|
59
|
+
throw new DeepInterviewCommandError(2, `invalid path component for --${label}: ${value}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function encodeSessionSegment(value: string): string {
|
|
64
|
+
return encodeURIComponent(value).replaceAll(".", "%2E");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface ResolvedDeepInterviewArgs {
|
|
68
|
+
resolution: DeepInterviewResolution;
|
|
69
|
+
threshold: number;
|
|
70
|
+
thresholdSource: string;
|
|
71
|
+
sessionId?: string;
|
|
72
|
+
idea: string;
|
|
73
|
+
json: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readSettingsAmbiguityThreshold(
|
|
77
|
+
settingsPath: string,
|
|
78
|
+
): Promise<{ threshold: number; source: string } | undefined> {
|
|
79
|
+
let raw: string;
|
|
80
|
+
try {
|
|
81
|
+
raw = await fs.readFile(settingsPath, "utf-8");
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const err = error as NodeJS.ErrnoException;
|
|
84
|
+
if (err.code === "ENOENT") return undefined;
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
let parsed: unknown;
|
|
88
|
+
try {
|
|
89
|
+
parsed = JSON.parse(raw);
|
|
90
|
+
} catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
const candidate = (parsed as { gjc?: { deepInterview?: { ambiguityThreshold?: unknown } } })?.gjc?.deepInterview
|
|
94
|
+
?.ambiguityThreshold;
|
|
95
|
+
if (typeof candidate !== "number" || !Number.isFinite(candidate) || candidate <= 0 || candidate > 1) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
return { threshold: candidate, source: settingsPath };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function resolveConfiguredAmbiguityThreshold(
|
|
102
|
+
cwd: string,
|
|
103
|
+
): Promise<{ threshold: number; source: string } | undefined> {
|
|
104
|
+
const projectSettings = path.join(cwd, ".gjc", "settings.json");
|
|
105
|
+
const projectValue = await readSettingsAmbiguityThreshold(projectSettings);
|
|
106
|
+
if (projectValue) return projectValue;
|
|
107
|
+
const configDir = process.env.GJC_CONFIG_DIR?.trim() || path.join(os.homedir(), ".gjc");
|
|
108
|
+
const userSettings = path.join(configDir, "settings.json");
|
|
109
|
+
return await readSettingsAmbiguityThreshold(userSettings);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function resolveDeepInterviewArgs(args: readonly string[], cwd: string): Promise<ResolvedDeepInterviewArgs> {
|
|
113
|
+
const sessionId = flagValue(args, "--session-id")?.trim() || undefined;
|
|
114
|
+
if (sessionId) assertSafePathComponent(sessionId, "session-id");
|
|
115
|
+
|
|
116
|
+
const explicitResolutions = (["quick", "standard", "deep"] as const).filter(name => hasFlag(args, `--${name}`));
|
|
117
|
+
if (explicitResolutions.length > 1) {
|
|
118
|
+
throw new DeepInterviewCommandError(2, "pass at most one of --quick, --standard, --deep");
|
|
119
|
+
}
|
|
120
|
+
const resolution: DeepInterviewResolution | undefined = explicitResolutions[0];
|
|
121
|
+
|
|
122
|
+
// Precedence: --threshold > settings.json (project then user) > resolution flag default > 0.05.
|
|
123
|
+
let threshold: number = DEFAULT_AMBIGUITY_THRESHOLD;
|
|
124
|
+
let thresholdSource = "default";
|
|
125
|
+
const thresholdOverride = flagValue(args, "--threshold");
|
|
126
|
+
if (thresholdOverride !== undefined) {
|
|
127
|
+
const parsed = Number(thresholdOverride);
|
|
128
|
+
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 1) {
|
|
129
|
+
throw new DeepInterviewCommandError(
|
|
130
|
+
2,
|
|
131
|
+
`invalid --threshold: ${thresholdOverride}. Expected 0 < threshold <= 1.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
threshold = parsed;
|
|
135
|
+
thresholdSource = flagValue(args, "--threshold-source")?.trim() || "flag:--threshold";
|
|
136
|
+
} else {
|
|
137
|
+
const configured = await resolveConfiguredAmbiguityThreshold(cwd);
|
|
138
|
+
if (configured) {
|
|
139
|
+
threshold = configured.threshold;
|
|
140
|
+
thresholdSource = configured.source;
|
|
141
|
+
} else if (resolution) {
|
|
142
|
+
threshold = RESOLUTION_THRESHOLDS[resolution];
|
|
143
|
+
thresholdSource = `flag:--${resolution}`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const ideaParts: string[] = [];
|
|
148
|
+
let skipNext = false;
|
|
149
|
+
for (const arg of args) {
|
|
150
|
+
if (skipNext) {
|
|
151
|
+
skipNext = false;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (VALUE_FLAGS.has(arg)) {
|
|
155
|
+
skipNext = true;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (arg === "--quick" || arg === "--standard" || arg === "--deep" || arg === "--json") continue;
|
|
159
|
+
if (arg.startsWith("-")) {
|
|
160
|
+
throw new DeepInterviewCommandError(2, `unknown flag for gjc deep-interview: ${arg}`);
|
|
161
|
+
}
|
|
162
|
+
ideaParts.push(arg);
|
|
163
|
+
}
|
|
164
|
+
const idea = ideaParts.join(" ").trim();
|
|
165
|
+
const effectiveResolution: DeepInterviewResolution = resolution ?? "standard";
|
|
166
|
+
return {
|
|
167
|
+
resolution: effectiveResolution,
|
|
168
|
+
threshold,
|
|
169
|
+
thresholdSource,
|
|
170
|
+
sessionId,
|
|
171
|
+
idea,
|
|
172
|
+
json: hasFlag(args, "--json"),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function seedDeepInterviewState(cwd: string, resolved: ResolvedDeepInterviewArgs): Promise<string> {
|
|
177
|
+
const stateDir = resolved.sessionId
|
|
178
|
+
? path.join(cwd, ".gjc", "state", "sessions", encodeSessionSegment(resolved.sessionId))
|
|
179
|
+
: path.join(cwd, ".gjc", "state");
|
|
180
|
+
await fs.mkdir(stateDir, { recursive: true });
|
|
181
|
+
const statePath = path.join(stateDir, "deep-interview-state.json");
|
|
182
|
+
const now = new Date().toISOString();
|
|
183
|
+
const payload: Record<string, unknown> = {
|
|
184
|
+
active: true,
|
|
185
|
+
current_phase: "interviewing",
|
|
186
|
+
skill: "deep-interview",
|
|
187
|
+
resolution: resolved.resolution,
|
|
188
|
+
threshold: resolved.threshold,
|
|
189
|
+
threshold_source: resolved.thresholdSource,
|
|
190
|
+
state: {
|
|
191
|
+
initial_idea: resolved.idea,
|
|
192
|
+
rounds: [],
|
|
193
|
+
current_ambiguity: 1.0,
|
|
194
|
+
threshold: resolved.threshold,
|
|
195
|
+
threshold_source: resolved.thresholdSource,
|
|
196
|
+
},
|
|
197
|
+
updated_at: now,
|
|
198
|
+
};
|
|
199
|
+
if (resolved.sessionId) payload.session_id = resolved.sessionId;
|
|
200
|
+
await fs.writeFile(statePath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
201
|
+
return statePath;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function syncDeepInterviewHud(options: {
|
|
205
|
+
cwd: string;
|
|
206
|
+
sessionId?: string;
|
|
207
|
+
phase: string;
|
|
208
|
+
ambiguity?: number;
|
|
209
|
+
threshold?: number;
|
|
210
|
+
roundCount?: number;
|
|
211
|
+
specStatus?: string;
|
|
212
|
+
}): Promise<void> {
|
|
213
|
+
try {
|
|
214
|
+
await syncSkillActiveState({
|
|
215
|
+
cwd: options.cwd,
|
|
216
|
+
skill: "deep-interview",
|
|
217
|
+
active: options.phase !== "complete",
|
|
218
|
+
phase: options.phase,
|
|
219
|
+
sessionId: options.sessionId,
|
|
220
|
+
source: "gjc-deep-interview-native",
|
|
221
|
+
hud: buildDeepInterviewHudSummary({
|
|
222
|
+
phase: options.phase,
|
|
223
|
+
ambiguity: options.ambiguity,
|
|
224
|
+
threshold: options.threshold,
|
|
225
|
+
roundCount: options.roundCount,
|
|
226
|
+
specStatus: options.specStatus,
|
|
227
|
+
updatedAt: new Date().toISOString(),
|
|
228
|
+
}),
|
|
229
|
+
});
|
|
230
|
+
} catch {
|
|
231
|
+
// HUD sync is best-effort and must not change command semantics.
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function runNativeDeepInterviewCommand(
|
|
236
|
+
args: string[],
|
|
237
|
+
cwd = process.cwd(),
|
|
238
|
+
): Promise<DeepInterviewCommandResult> {
|
|
239
|
+
try {
|
|
240
|
+
const resolved = await resolveDeepInterviewArgs(args, cwd);
|
|
241
|
+
if (!resolved.idea) {
|
|
242
|
+
throw new DeepInterviewCommandError(
|
|
243
|
+
2,
|
|
244
|
+
'gjc deep-interview requires an idea, e.g. `gjc deep-interview "<idea>"`.',
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
const statePath = await seedDeepInterviewState(cwd, resolved);
|
|
248
|
+
await syncDeepInterviewHud({
|
|
249
|
+
cwd,
|
|
250
|
+
sessionId: resolved.sessionId,
|
|
251
|
+
phase: "interviewing",
|
|
252
|
+
ambiguity: 1,
|
|
253
|
+
threshold: resolved.threshold,
|
|
254
|
+
roundCount: 0,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const summary = {
|
|
258
|
+
skill: "deep-interview",
|
|
259
|
+
resolution: resolved.resolution,
|
|
260
|
+
threshold: resolved.threshold,
|
|
261
|
+
threshold_source: resolved.thresholdSource,
|
|
262
|
+
idea: resolved.idea,
|
|
263
|
+
state_path: statePath,
|
|
264
|
+
handoff: "Run `/skill:deep-interview` inside the GJC agent to drive the Socratic interview loop.",
|
|
265
|
+
};
|
|
266
|
+
const stdout = resolved.json
|
|
267
|
+
? `${JSON.stringify(summary, null, 2)}\n`
|
|
268
|
+
: [
|
|
269
|
+
`Seeded deep-interview ${resolved.resolution} run at ${statePath}.`,
|
|
270
|
+
`Threshold: ${(resolved.threshold * 100).toFixed(0)}% (source: ${resolved.thresholdSource}).`,
|
|
271
|
+
"Run `/skill:deep-interview` inside the GJC agent to execute the interview.",
|
|
272
|
+
"",
|
|
273
|
+
].join("\n");
|
|
274
|
+
return { status: 0, stdout };
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (error instanceof DeepInterviewCommandError) return { status: error.exitStatus, stderr: `${error.message}\n` };
|
|
277
|
+
return { status: 1, stderr: `${error instanceof Error ? error.message : String(error)}\n` };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { Snowflake } from "@gajae-code/utils";
|
|
4
|
-
import type
|
|
4
|
+
import { type Goal, type GoalModeState, normalizeGoal } from "../goals/state";
|
|
5
5
|
import {
|
|
6
6
|
buildSessionContext,
|
|
7
7
|
loadEntriesFromFile,
|
|
@@ -94,24 +94,7 @@ export async function writePendingGoalModeRequest(input: {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
function goalFromModeData(modeData: Record<string, unknown> | undefined): Goal | null {
|
|
97
|
-
|
|
98
|
-
if (typeof candidate !== "object" || candidate === null) return null;
|
|
99
|
-
const goal = candidate as Partial<Goal>;
|
|
100
|
-
if (
|
|
101
|
-
typeof goal.id !== "string" ||
|
|
102
|
-
typeof goal.objective !== "string" ||
|
|
103
|
-
typeof goal.status !== "string" ||
|
|
104
|
-
typeof goal.tokensUsed !== "number" ||
|
|
105
|
-
typeof goal.timeUsedSeconds !== "number" ||
|
|
106
|
-
typeof goal.createdAt !== "number" ||
|
|
107
|
-
typeof goal.updatedAt !== "number"
|
|
108
|
-
) {
|
|
109
|
-
return null;
|
|
110
|
-
}
|
|
111
|
-
if (!["active", "paused", "budget-limited", "complete", "dropped"].includes(goal.status)) {
|
|
112
|
-
return null;
|
|
113
|
-
}
|
|
114
|
-
return goal as Goal;
|
|
97
|
+
return normalizeGoal(modeData?.goal);
|
|
115
98
|
}
|
|
116
99
|
|
|
117
100
|
function isNonTerminalGoal(goal: Goal | null): goal is Goal {
|
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import type { Args } from "../cli/args";
|
|
3
|
+
import {
|
|
4
|
+
buildGjcTmuxProfileCommands,
|
|
5
|
+
buildGjcTmuxSessionName,
|
|
6
|
+
buildGjcTmuxSessionSlug,
|
|
7
|
+
GJC_DEFAULT_TMUX_SESSION,
|
|
8
|
+
GJC_TMUX_COMMAND_ENV,
|
|
9
|
+
GJC_TMUX_MOUSE_ENV,
|
|
10
|
+
GJC_TMUX_PROFILE_ENV,
|
|
11
|
+
GJC_TMUX_SESSION_PREFIX,
|
|
12
|
+
type GjcTmuxProfileCommand,
|
|
13
|
+
resolveGjcTmuxCommand,
|
|
14
|
+
} from "./tmux-common";
|
|
15
|
+
import { findGjcTmuxSessionByBranch } from "./tmux-sessions";
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
buildGjcTmuxProfileCommands,
|
|
19
|
+
GJC_DEFAULT_TMUX_SESSION,
|
|
20
|
+
GJC_TMUX_COMMAND_ENV,
|
|
21
|
+
GJC_TMUX_MOUSE_ENV,
|
|
22
|
+
GJC_TMUX_PROFILE_ENV,
|
|
23
|
+
GJC_TMUX_SESSION_PREFIX,
|
|
24
|
+
};
|
|
3
25
|
|
|
4
|
-
export const GJC_DEFAULT_TMUX_SESSION = "gajae_code";
|
|
5
26
|
export const GJC_TMUX_LAUNCHED_ENV = "GJC_TMUX_LAUNCHED";
|
|
6
27
|
export const GJC_LAUNCH_POLICY_ENV = "GJC_LAUNCH_POLICY";
|
|
7
|
-
export const GJC_TMUX_COMMAND_ENV = "GJC_TMUX_COMMAND";
|
|
8
|
-
export const GJC_TMUX_PROFILE_ENV = "GJC_TMUX_PROFILE";
|
|
9
|
-
export const GJC_TMUX_MOUSE_ENV = "GJC_MOUSE";
|
|
10
28
|
|
|
11
29
|
type LaunchPolicy = "direct" | "tmux";
|
|
12
30
|
|
|
@@ -26,6 +44,10 @@ export interface TmuxLaunchContext {
|
|
|
26
44
|
tty?: TtyState;
|
|
27
45
|
spawnSync?: TmuxSpawnSync;
|
|
28
46
|
tmuxAvailable?: boolean;
|
|
47
|
+
worktreeBranch?: string | null;
|
|
48
|
+
currentBranch?: string | null;
|
|
49
|
+
existingBranchSessionName?: string | null;
|
|
50
|
+
project?: string | null;
|
|
29
51
|
}
|
|
30
52
|
|
|
31
53
|
export interface TmuxSpawnResult {
|
|
@@ -50,12 +72,9 @@ export interface TmuxLaunchPlan {
|
|
|
50
72
|
cwd: string;
|
|
51
73
|
innerCommand: string;
|
|
52
74
|
newSessionArgs: string[];
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
export interface GjcTmuxProfileCommand {
|
|
57
|
-
description: string;
|
|
58
|
-
args: string[];
|
|
75
|
+
branch?: string | null;
|
|
76
|
+
attachSessionName?: string;
|
|
77
|
+
project?: string | null;
|
|
59
78
|
}
|
|
60
79
|
|
|
61
80
|
export interface GjcTmuxProfileResult {
|
|
@@ -70,6 +89,9 @@ export interface GjcTmuxProfileContext {
|
|
|
70
89
|
cwd?: string;
|
|
71
90
|
env?: NodeJS.ProcessEnv;
|
|
72
91
|
spawnSync?: TmuxSpawnSync;
|
|
92
|
+
branch?: string | null;
|
|
93
|
+
branchSlug?: string | null;
|
|
94
|
+
project?: string | null;
|
|
73
95
|
}
|
|
74
96
|
|
|
75
97
|
interface CommandResolutionContext {
|
|
@@ -103,35 +125,14 @@ function shellQuote(value: string): string {
|
|
|
103
125
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
104
126
|
}
|
|
105
127
|
|
|
106
|
-
function envDisabled(value: string | undefined): boolean {
|
|
107
|
-
const normalized = value?.trim().toLowerCase();
|
|
108
|
-
return normalized === "0" || normalized === "false" || normalized === "off" || normalized === "no";
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export function buildGjcTmuxProfileCommands(
|
|
112
|
-
target: string,
|
|
113
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
114
|
-
): GjcTmuxProfileCommand[] {
|
|
115
|
-
if (envDisabled(env[GJC_TMUX_PROFILE_ENV])) return [];
|
|
116
|
-
const commands: GjcTmuxProfileCommand[] = [
|
|
117
|
-
{ description: "mark GJC tmux ownership", args: ["set-option", "-t", target, "@gjc-profile", "1"] },
|
|
118
|
-
{ description: "enable tmux clipboard integration", args: ["set-option", "-t", target, "set-clipboard", "on"] },
|
|
119
|
-
{
|
|
120
|
-
description: "make copy-mode selection readable",
|
|
121
|
-
args: ["set-window-option", "-t", target, "mode-style", "fg=colour231,bg=colour60"],
|
|
122
|
-
},
|
|
123
|
-
];
|
|
124
|
-
if (!envDisabled(env[GJC_TMUX_MOUSE_ENV]))
|
|
125
|
-
commands.unshift({
|
|
126
|
-
description: "enable tmux mouse scrolling",
|
|
127
|
-
args: ["set-option", "-t", target, "mouse", "on"],
|
|
128
|
-
});
|
|
129
|
-
return commands;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
128
|
export function applyGjcTmuxProfile(context: GjcTmuxProfileContext): GjcTmuxProfileResult {
|
|
133
129
|
const env = context.env ?? process.env;
|
|
134
|
-
const
|
|
130
|
+
const branchSlug = context.branch ? buildGjcTmuxSessionSlug(context.branch) : (context.branchSlug ?? null);
|
|
131
|
+
const commands = buildGjcTmuxProfileCommands(context.target, env, {
|
|
132
|
+
branch: context.branch ?? null,
|
|
133
|
+
branchSlug,
|
|
134
|
+
project: context.project ?? null,
|
|
135
|
+
});
|
|
135
136
|
if (commands.length === 0) return { skipped: true, commands: [], failures: [] };
|
|
136
137
|
const spawnSync = context.spawnSync ?? defaultSpawnSync;
|
|
137
138
|
const cwd = context.cwd ?? process.cwd();
|
|
@@ -160,6 +161,25 @@ function buildInnerCommand(context: CommandResolutionContext, rawArgs: string[])
|
|
|
160
161
|
return `exec env ${GJC_TMUX_LAUNCHED_ENV}=1 ${quoted}`;
|
|
161
162
|
}
|
|
162
163
|
|
|
164
|
+
function readCurrentBranch(cwd: string): string | null {
|
|
165
|
+
try {
|
|
166
|
+
const result = Bun.spawnSync(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], {
|
|
167
|
+
cwd,
|
|
168
|
+
stdout: "pipe",
|
|
169
|
+
stderr: "ignore",
|
|
170
|
+
});
|
|
171
|
+
if (result.exitCode !== 0) return null;
|
|
172
|
+
const branch = result.stdout.toString().trim();
|
|
173
|
+
return branch || null;
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function cleanupCreatedTmuxSession(plan: TmuxLaunchPlan, spawnSync: TmuxSpawnSync, options: TmuxSpawnOptions): void {
|
|
180
|
+
spawnSync(plan.tmuxCommand, ["kill-session", "-t", `=${plan.sessionName}`], options);
|
|
181
|
+
}
|
|
182
|
+
|
|
163
183
|
export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaunchPlan | undefined {
|
|
164
184
|
const env = context.env ?? process.env;
|
|
165
185
|
const policy = parseLaunchPolicy(env);
|
|
@@ -171,10 +191,18 @@ export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaun
|
|
|
171
191
|
if (policy === "tmux" && !isInteractiveRootLaunch(context.parsed, tty)) return undefined;
|
|
172
192
|
|
|
173
193
|
const cwd = context.cwd ?? process.cwd();
|
|
174
|
-
const
|
|
175
|
-
const
|
|
194
|
+
const branch = context.worktreeBranch ?? context.currentBranch ?? readCurrentBranch(cwd);
|
|
195
|
+
const project = context.project ?? cwd;
|
|
196
|
+
const sessionName = buildGjcTmuxSessionName(env, { branch });
|
|
197
|
+
const tmuxCommand = resolveGjcTmuxCommand(env);
|
|
176
198
|
const tmuxAvailable = context.tmuxAvailable ?? Bun.which(tmuxCommand) !== null;
|
|
177
199
|
if (!tmuxAvailable) return undefined;
|
|
200
|
+
const existingBranchSessionName =
|
|
201
|
+
"existingBranchSessionName" in context
|
|
202
|
+
? (context.existingBranchSessionName ?? undefined)
|
|
203
|
+
: context.worktreeBranch
|
|
204
|
+
? findGjcTmuxSessionByBranch(context.worktreeBranch, env, project)?.name
|
|
205
|
+
: undefined;
|
|
178
206
|
const innerCommand = buildInnerCommand(
|
|
179
207
|
{
|
|
180
208
|
cwd,
|
|
@@ -189,7 +217,9 @@ export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaun
|
|
|
189
217
|
cwd,
|
|
190
218
|
innerCommand,
|
|
191
219
|
newSessionArgs: ["new-session", "-d", "-s", sessionName, "-c", cwd, innerCommand],
|
|
192
|
-
|
|
220
|
+
branch,
|
|
221
|
+
project,
|
|
222
|
+
attachSessionName: existingBranchSessionName,
|
|
193
223
|
};
|
|
194
224
|
}
|
|
195
225
|
|
|
@@ -217,17 +247,27 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
|
|
|
217
247
|
stdout: "inherit",
|
|
218
248
|
stderr: "inherit",
|
|
219
249
|
};
|
|
250
|
+
if (plan.attachSessionName) {
|
|
251
|
+
const attached = spawnSync(plan.tmuxCommand, ["attach-session", "-t", `=${plan.attachSessionName}`], options);
|
|
252
|
+
return attached.exitCode === 0;
|
|
253
|
+
}
|
|
220
254
|
const created = spawnSync(plan.tmuxCommand, plan.newSessionArgs, options);
|
|
221
255
|
if (created.exitCode === 0) {
|
|
222
|
-
applyGjcTmuxProfile({
|
|
256
|
+
const profile = applyGjcTmuxProfile({
|
|
223
257
|
tmuxCommand: plan.tmuxCommand,
|
|
224
258
|
target: plan.sessionName,
|
|
225
259
|
cwd: plan.cwd,
|
|
226
260
|
env,
|
|
227
261
|
spawnSync,
|
|
262
|
+
branch: plan.branch,
|
|
263
|
+
project: plan.project,
|
|
228
264
|
});
|
|
265
|
+
if (profile.failures.length > 0) {
|
|
266
|
+
cleanupCreatedTmuxSession(plan, spawnSync, options);
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
229
269
|
}
|
|
230
|
-
|
|
231
|
-
|
|
270
|
+
if (created.exitCode !== 0) return false;
|
|
271
|
+
const attached = spawnSync(plan.tmuxCommand, ["attach-session", "-t", plan.sessionName], options);
|
|
232
272
|
return attached.exitCode === 0;
|
|
233
273
|
}
|