feat(session-kind): explicit GSTACK_SESSION_KIND override; skill-start spawned gates keyed on kind (#2733)

Claude Code subagents inherit the parent env byte-for-byte, so ambient
markers classify them as the parent's kind and the spawned classification
was unreachable outside OpenClaw. GSTACK_SESSION_KIND=spawned (step 0,
spawned-only by design) lets a dispatching skill mark its subagent per
command. skill-start now keys SPAWNED_SESSION and the spawned-session
instruction block on the resolved kind (was raw OPENCLAW_SESSION),
suppresses CONDUCTOR_SESSION for spawned sessions, gates all 11
interactive-onboarding blocks plus their ack-at-emit marker writes on
kind != spawned, and adds a destructive-gate carve-out to the spawned
block (conservative-continue, never prose-STOP).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-30 23:33:40 +00:00
co-authored by Claude Fable 5
parent 07b59e396c
commit 5486cd6620
5 changed files with 136 additions and 6 deletions
+31
View File
@@ -68,3 +68,34 @@ describe('gstack-session-kind', () => {
expect(kind({ GSTACK_HEADLESS: '' })).toBe('interactive');
});
});
describe('GSTACK_SESSION_KIND explicit override (#2733)', () => {
test('spawned wins over every ambient marker (step 0, explicit beats ambient)', () => {
// Claude Code subagents inherit the parent env byte-for-byte, so the
// per-command marker must outrank whatever the parent session looks like.
expect(kind({ GSTACK_SESSION_KIND: 'spawned' })).toBe('spawned');
expect(kind({ GSTACK_SESSION_KIND: 'spawned', CONDUCTOR_PORT: '5' })).toBe('spawned');
expect(kind({ GSTACK_SESSION_KIND: 'spawned', CONDUCTOR_WORKSPACE_PATH: '/x', CI: '1' })).toBe('spawned');
expect(kind({ GSTACK_SESSION_KIND: 'spawned', GSTACK_HEADLESS: '1' })).toBe('spawned');
expect(kind({ GSTACK_SESSION_KIND: 'spawned', CLAUDE_CODE_ENTRYPOINT: 'cli' })).toBe('spawned');
});
test('only "spawned" is honored — reserved values fall through to detection', () => {
// Deliberately narrow: "headless" already has GSTACK_HEADLESS, and letting
// an env var force "interactive" over CI markers would be a footgun.
expect(kind({ GSTACK_SESSION_KIND: 'headless' })).toBe('interactive');
expect(kind({ GSTACK_SESSION_KIND: 'headless', OPENCLAW_SESSION: '1' })).toBe('spawned');
expect(kind({ GSTACK_SESSION_KIND: 'interactive', CI: '1' })).toBe('headless');
});
test('invalid values are ignored (case-sensitive)', () => {
expect(kind({ GSTACK_SESSION_KIND: 'bogus' })).toBe('interactive');
expect(kind({ GSTACK_SESSION_KIND: 'bogus', CI: '1' })).toBe('headless');
expect(kind({ GSTACK_SESSION_KIND: 'SPAWNED' })).toBe('interactive');
});
test('empty GSTACK_SESSION_KIND is treated as unset', () => {
expect(kind({ GSTACK_SESSION_KIND: '' })).toBe('interactive');
expect(kind({ GSTACK_SESSION_KIND: '', OPENCLAW_SESSION: '1' })).toBe('spawned');
});
});